instance

package
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 18, 2026 License: MIT Imports: 14 Imported by: 0

Documentation

Overview

Package instance is what belongs to the box rather than to a tenant in it: who administers it, what they may hand to somebody else, and — since M55 — the settings an operator answers for the instance as a whole.

D38 recorded that this product had no such principal, and three findings bottomed out there. D98 introduces one, with two constraints that shape everything here:

**Only the instance-owner level may delegate.** The principal confers instance-level review; a holder of instance-level review may not confer it onwards. That is structural rather than checked — `auth.InstanceGrantable` does not contain `instance.admin`, so there is no path by which a grant produces another grantor, and the set of people who may delegate cannot grow. It is the same argument D43 makes about key-issued invitations: bound what a grant may produce, not only who may make one.

**A change requires a person.** Not implemented here, and deliberately: it is `destinations.decide` sitting in `auth.NonDelegableScopes`, so a key is refused by the map rather than by a check on what kind of credential is calling. This package contains no reference to `IsAPIKey`, and that absence is the design.

The scopes are enumerated in internal/auth and nothing inherits from holding one. A principal that accumulates scopes because it exists is how the thing D38 avoided gets built by accident.

Index

Constants

View Source
const (
	ActionReviewerGranted = "instance.reviewer_granted"
	ActionReviewerRevoked = "instance.reviewer_revoked"
	// ActionPrincipalMoved is the operator's recovery (F140). Its actor is
	// recorded as `system`, because nobody in the product performed it; see
	// MovePrincipal.
	ActionPrincipalMoved = "instance.principal_moved"
)

Audit actions for the writes here. All three are instance-wide events: they change who may act on every organization's disputes, so they belong to no tenant.

View Source
const PermAdmin = auth.PermInstanceAdmin

PermAdmin guards conferring and withdrawing instance-level review.

Re-exported from internal/auth rather than declared again, because the identity resolver has to know the slug and this package must not be able to drift from it.

Variables

View Source
var ErrAlreadyAnswered = errors.New("instance: the update-check question has already been answered")

ErrAlreadyAnswered is what AnswerUpdateCheck says when the question is closed.

Its own error because the handler's response to it is *not* an error page: the operator asked for a state the instance is already in, which is a success from where they are standing. Distinguishing it from a database failure is the whole reason it exists — the two must not produce the same page.

View Source
var ErrClaimed = errors.New("instance: this instance has already been claimed")

ErrClaimed is what a setup-only operation answers once the instance has an account on it.

Its own error so a caller can tell "the window has closed" from a database failure, and so the refusal reads the same way `POST /setup` already answers a claimed instance.

Functions

This section is empty.

Types

type Config

type Config struct {
	// Audit records the three writes. Nil records nothing.
	Audit audit.Recorder
	Log   *slog.Logger
}

Config is what a Service needs.

type Principal

type Principal struct {
	UserID uuid.UUID
	Email  string
	Name   string
}

Principal is an account that administers the instance, as an operator at a shell sees it.

type Reviewer

type Reviewer struct {
	UserID uuid.UUID `json:"user_id"`
	Email  string    `json:"email"`
	Name   string    `json:"name"`
	// GrantedAt is when they were appointed. GrantedBy is who appointed them,
	// absent for the two grants nobody performed interactively — the bootstrap in
	// migration 03400 and the setup flow that claimed a fresh instance.
	GrantedAt time.Time  `json:"granted_at"`
	GrantedBy *uuid.UUID `json:"granted_by,omitempty"`
	// CanDecide separates the two halves of the dispute permission. A reviewer
	// normally holds both; the field exists because the halves are separately
	// revocable and a list that folded them together would show a state the
	// database can be in as a state it cannot.
	CanDecide bool `json:"can_decide"`
}

Reviewer is somebody holding instance-level review, as the principal sees them.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service reads and writes instance-level grants.

func NewService

func NewService(pool *pgxpool.Pool, cfg Config) *Service

func (*Service) AnswerUpdateCheck added in v0.3.0

func (s *Service) AnswerUpdateCheck(ctx context.Context, actor *auth.Identity, enabled bool) error

AnswerUpdateCheck records an administrator's answer to the question an upgraded instance is asked at its first administrative sign-in (M55, D164).

Once, and never a change of mind

The write is conditional on the question still being open, in one statement, so this is a first answer or it is nothing. That bound is what keeps the route from becoming the instance-settings page D161 declined to build: there is no second answer to give through it, so it cannot grow into a control surface, and the one thing an operator can always still do is set `LINKCTRL_UPDATE_CHECK=false` where the rest of the deployment is configured.

A second submission — two tabs, a double click, a second principal who was also looking at the prompt — answers ErrAlreadyAnswered, which the caller treats as *the question is closed* rather than as a failure. That is why it is its own error and not a row count returned to a handler to interpret.

Why it takes an actor when SetUpdateCheckAtSetup does not

Its sibling runs before there is anybody to be, and rests on the same filesystem-access authority `auth.Register` rests the setup flow on. This one runs on a claimed instance where there are accounts, most of which must not be able to decide what the box connects to. So it is the ordinary permission check, in the service rather than in the handler, for the reason SetUpdateCheckAtSetup guards itself: what is reachable from HTTP is guarded where the write is.

No audit row. The three actions above are changes to *who may act on every organization's disputes*; this is an operator answering a question about their own box, once, with no subject and nobody to be accountable to but themselves. A record of it that no surface reads would be a row written to look thorough.

func (*Service) GrantReviewer

func (s *Service) GrantReviewer(
	ctx context.Context, actor *auth.Identity, email string,
) (*Reviewer, error)

GrantReviewer confers instance-level review on the account with an address.

By address rather than by id, because the principal appointing somebody knows who they are and not what their uuid is, and because the address is what both surfaces already collect.

Idempotent, so appointing somebody who is already a reviewer succeeds. Two administrators doing the same obvious thing must not produce something that reads like a refusal.

func (*Service) MovePrincipal

func (s *Service) MovePrincipal(ctx context.Context, email string) (*Transfer, error)

MovePrincipal moves the instance principal onto the account with an address, and off every account that held it.

**F140: the gap D98 named and left open.** The principal is conferred at `POST /api/v1/auth/setup` and nowhere else, `InstanceGrantable` deliberately excludes `instance.admin` so that no holder can mint another, and nothing in the product deletes a `users` row — so a conferred principal cannot be *lost* through the product, but the account it sits on can be. A forgotten password with no mailer configured, a departed colleague, an instance whose earliest surviving account was never the operator's: each of those ended at `psql`. F141 is now closed by M51 and narrows the first of the three rather than removing it — the password can be reset by its owner **when a mailer is configured**, and with none the principal's password and the principal are still the same thing to lose, which is the case this command exists for.

**It takes no actor, and that is the design rather than an omission.** The authority here is the shell. An operator running `lctl` has filesystem and deploy access, which is the same claim to the box that `internal/auth`'s `Register` already rests the whole setup flow on — *the first user is trusted by construction: they had filesystem or deploy access to reach the setup page*. Asking for an `*auth.Identity` and checking `Can(PermAdmin)` would be theatre, because whoever runs this picks the identity; worse, it would read as an in-product authorization and invite somebody to hang an HTTP route off it. Nothing routes here, and the absence of a permission check is what says so.

**D98's delegation bound is not widened, and this asserts it rather than claiming it.** A move is not a grant: the transaction ends with exactly one account holding `instance.admin`, verified before the commit, so the set of people who may delegate cannot grow — which is the property the bound exists to protect, and the same direction migration 03400 took when it moved the role grant onto the earliest surviving account. An operation that *added* a principal is the one D98 forbids, and this is not one.

Idempotent, like GrantReviewer and for the same reason: moving the principal onto the account that already holds it succeeds and changes nothing.

func (*Service) Principals

func (s *Service) Principals(ctx context.Context) ([]Principal, error)

Principals is who holds `instance.admin`, for an operator at a shell.

It takes no actor for the reason MovePrincipal takes none, and it exists because a move that could not be checked before or after would be a repair performed blind: the state this whole gap is about — *which account administers this instance* — had no answer outside `psql`, and an operator who cannot read it cannot tell a successful move from a move onto the wrong address.

Deliberately not the same thing as Reviewers, which is gated on PermAdmin and lists who was *appointed*. This lists who does the appointing, and there is normally exactly one.

func (*Service) Reviewers

func (s *Service) Reviewers(ctx context.Context, actor *auth.Identity) ([]Reviewer, error)

Reviewers lists who holds instance-level review.

Gated on PermAdmin rather than on holding review itself. Who else administers the instance is the principal's business; a reviewer needs the queue, not the roster, and handing them one would disclose the full set of administrators to everybody appointed to work through disputes.

func (*Service) RevokeReviewer

func (s *Service) RevokeReviewer(
	ctx context.Context, actor *auth.Identity, userID uuid.UUID,
) error

RevokeReviewer withdraws instance-level review from an account.

It cannot withdraw `instance.admin`, because that scope is not in InstanceGrantable and this loop is over that set: the principal is not removable through the surface it uses to appoint people, which is the same reason it is not conferrable through it. An instance that could revoke its own last principal would be an instance with nobody able to appoint one, and the dispute queue would be stranded exactly as it is stranded today by the finding this whole change closes.

func (*Service) SetUpdateCheckAtSetup added in v0.3.0

func (s *Service) SetUpdateCheckAtSetup(ctx context.Context, enabled bool) error

SetUpdateCheckAtSetup records the operator's answer to the first-run prompt (M55, D149).

Why this is here and takes no actor

The instance-level settings row belongs to the box rather than to a tenant in it, which is this package, and the only thing that writes it is the setup form — where there is no actor, because there is no account yet. That is the same authority `auth.Register` rests the whole setup flow on: whoever reaches the page had filesystem or deploy access to reach it.

**The precondition is checked here rather than trusted from the caller**, and that is the difference between this and MovePrincipal one screen down. That one is unreachable from HTTP and says so; this one is reached by two HTTP handlers, so an unchecked version would be a public endpoint for changing what an instance connects to, one route registration away from existing. Counting the users is the same question `auth.Service.NeedsSetup` asks, asked again at the moment of the write.

Not atomic with the account's creation, and it does not need to be: the caller writes the answer *before* claiming the instance, so a claim that then fails leaves the answer standing and the operator's retry rewrites it. The failure this ordering rules out is the one that matters — an operator declining the check and being checked on anyway.

func (*Service) UpdateCheckAnswered added in v0.3.0

func (s *Service) UpdateCheckAnswered(ctx context.Context, actor *auth.Identity) (bool, error)

UpdateCheckAnswered reports whether anybody has answered the update-check question on this instance (M55, D164).

False is the state an instance **upgrading** into 0.3.0 arrives in, and it is the one thing the prompt at the first administrative sign-in is drawn from. It is not *the check is off* — that is decided in the statement that claims the day, and asking two places the same question is how the two answers eventually differ. Unanswered does imply off, and only in that direction.

Gated on PermAdmin, like Reviewers and for a narrower version of the same reason: this is the state of the box's own configuration, and the only caller is drawing a control that only a principal may use. A read whose answer nobody else may act on has no business being readable by everybody.

type Transfer

type Transfer struct {
	To   Principal
	From []Principal
}

Transfer is what one move did: who holds the principal now, and who held it before and does not any more.

From is a slice because the table can hold more than one row and this package is not the only thing that has ever written to it — migration 03400 and the setup flow both do, and an operator with `psql` is the reason this method exists at all. Reporting the set rather than "the previous principal" means a box that had two says so instead of naming one of them.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL