Help us shape the future of Go! Take the 2026 Go Developer Survey

service

package
v0.1.5 Latest Latest
Warning

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

Go to latest
Published: Oct 29, 2026 License: Apache-2.0 Imports: 43 Imported by: 0

Documentation

Index

Constants

View Source
const (
	LoginMaxFailures = 10
	LoginLockoutFor  = 15 * time.Minute

	LoginEmailAlertThreshold = 50
)
View Source
const MFAPendingMaxAttempts = 5
View Source
const OAuthStateCookieName = "__Host-oauth_state"

Variables

View Source
var (
	// ErrSoleOwner refuses to delete an account that is the only owner of an
	// organization. Not a formality: the organization would be left with nobody
	// who can add an owner, change a role, or delete it — an unadministrable
	// tenant that only platform staff can rescue.
	ErrSoleOwner = errors.New("account: this account is the only owner of an organization")

	// ErrConfirmationMismatch refuses a destructive action whose confirmation
	// does not match.
	ErrConfirmationMismatch = errors.New("account: the confirmation does not match")

	ErrOrgNotFound     = errors.New("account: no such organization")
	ErrAccountNotFound = errors.New("account: no such account")
)
View Source
var (
	// ErrUnprovenOrgCeiling is a 403, deliberately not a 429: the ceiling has no
	// time component, so "try again later" would be a lie. Waiting restores
	// nothing; only an organization becoming PROVEN frees capacity.
	ErrUnprovenOrgCeiling = errors.New("admission: this email domain already holds too many unproven organizations")

	// ErrUnresolvableDomain is a 422. The address cannot be reduced to an
	// eTLD+1, so there is no bucket to count it against and no domain a
	// registrar could ever act on. That is a refusal, never a fallthrough.
	ErrUnresolvableDomain = errors.New("admission: cannot determine a registrable domain for this address")

	// ErrAdmissionNotConfigured is a 500. It exists so that a gate that was
	// never wired denies instead of panicking or, far worse, waving everything
	// through: an unwired admission gate must not look like an empty rule set.
	ErrAdmissionNotConfigured = errors.New("admission: gate is not configured")
)
View Source
var (
	// ErrAPIKeyRejected covers unknown, revoked, expired and wrong-secret. One
	// error for all four: this is reached by anything that can present a
	// credential, so distinguishing them would turn the endpoint into an oracle
	// over keys the caller does not hold.
	ErrAPIKeyRejected = errors.New("api key: rejected")

	// ErrAPIKeyWrongSurface is separate because it is NOT an authentication
	// failure — the credential is genuine, it is simply not admitted here. An
	// agent key on an org endpoint is a misconfigured deployment, and telling
	// its operator "rejected" would send them looking for a credential problem
	// that does not exist.
	ErrAPIKeyWrongSurface = errors.New("api key: not valid on this surface")
)
View Source
var (
	// ErrVerificationTokenInvalid covers expired, already-used, unknown, and
	// issued-for-a-different-address. They are one error on purpose: telling a
	// caller which of those a token is turns the endpoint into an oracle over
	// tokens they do not hold.
	ErrVerificationTokenInvalid = errors.New("verification: link is invalid or has expired")

	// ErrEmailNotVerified is returned by the login path. It is separate from
	// ErrInvalidCredentials because the caller supplied a CORRECT password — the
	// UI has to be able to route them to "resend the link" rather than "you
	// typed it wrong". See the note in LoginService.Login on why that disclosure
	// is acceptable here and would not be at the identification step.
	ErrEmailNotVerified = errors.New("verification: this address has not been confirmed yet")
)
View Source
var (
	ErrTenantUnavailable   = errors.New("estate: this organization's tenant database is not available")
	ErrEstateHasCredential = errors.New("estate: a credential is still bound to this estate")
)
View Source
var (
	ErrEnrolmentRefused = errors.New("fleet: enrolment refused")
	ErrLogBatchTooLarge = errors.New("fleet: log batch is too large")
)
View Source
var (
	ErrImpersonationNotAllowed = errors.New("impersonation not allowed")
	ErrTargetUserNotFound      = errors.New("target user not found")
	ErrTargetUserInactive      = errors.New("target user is inactive")
	ErrMembershipNotFound      = errors.New("membership not found in organization")
)
View Source
var (
	// ErrInvitationInvalid covers unknown, expired, revoked and already
	// accepted. One error for all four: the endpoint is unauthenticated, so
	// distinguishing them would let anyone holding a guess probe the table.
	ErrInvitationInvalid = errors.New("invitation: this invitation is invalid or has expired")

	// ErrAlreadyMember is not an error the invitee sees — accepting when you are
	// already in is reported as success. It is returned to the INVITER, who is
	// asking for something that would do nothing.
	ErrAlreadyMember = errors.New("invitation: that address is already a member of this organization")

	// ErrPasswordRequired is returned when a brand-new invitee accepts without
	// choosing one. There is no account to fall back on.
	ErrPasswordRequired = errors.New("invitation: choose a password to finish setting up your account")

	// ErrPasswordNotAccepted is its inverse: an invitee who ALREADY has an
	// account may not set a password through this path. Accepting an invitation
	// proves control of a mailbox, not knowledge of an existing account's
	// password, and letting it rewrite one would make any invitation to a known
	// address a full account takeover.
	ErrPasswordNotAccepted = errors.New("invitation: this address already has an account — sign in to accept")

	ErrCannotInviteSelf = errors.New("invitation: you are already a member")
)
View Source
var (
	ErrInvalidCredentials = errors.New("invalid credentials")
	ErrUserInactive       = errors.New("user inactive")
	ErrNoOrganization     = errors.New("user has no organization membership")
)
View Source
var (
	// ErrMembershipNotInOrg is returned when a membership id names a row that
	// belongs to a different organization. It is reported as 404, identical to a
	// row that does not exist, so the endpoint cannot be used to discover which
	// membership ids are real.
	ErrMembershipNotInOrg = errors.New("membership: no such membership in this organization")

	// ErrLastOwner guards the one state an organization can never be in.
	ErrLastOwner = errors.New("membership: an organization must keep at least one owner")

	// ErrOwnerRoleRequiresOwner: admins manage members and admins. Granting or
	// revoking ownership is an owner's decision. Without this an admin could
	// promote themselves to owner, which makes the owner role decorative.
	ErrOwnerRoleRequiresOwner = errors.New("membership: only an owner may grant or revoke the owner role")
)
View Source
var (
	ErrTOTPAlreadySetup = errors.New("TOTP already configured")
	ErrTOTPNotSetup     = errors.New("TOTP not configured")
	ErrTOTPNotVerified  = errors.New("TOTP not verified")
	ErrInvalidTOTPCode  = errors.New("invalid TOTP code")
)
View Source
var (
	ErrOrgQuotaExceeded        = errors.New("organization quota exceeded")
	ErrOrgCreationRateExceeded = errors.New("organization creation rate exceeded")
)
View Source
var (
	ErrPasskeyNotFound     = errors.New("passkey not found")
	ErrPasskeyChallenge    = errors.New("invalid passkey challenge")
	ErrPasskeyRegistration = errors.New("passkey registration failed")
	ErrPasskeyLogin        = errors.New("passkey login failed")
)
View Source
var (
	ErrResetTokenInvalid = errors.New("reset token is invalid or expired")
	ErrResetTokenUsed    = errors.New("reset token has already been used")
)
View Source
var (
	ErrUserNotFound    = errors.New("user not found")
	ErrWrongPassword   = errors.New("current password is incorrect")
	ErrNothingToUpdate = errors.New("nothing to update")
)
View Source
var (
	ErrEmailTaken       = errors.New("registration: that email is already registered")
	ErrRegistrationName = errors.New("registration: a name is required")
	ErrWeakPassword     = errors.New("registration: password does not meet policy")
)
View Source
var (
	ErrUnsupportedProvider = errors.New("unsupported provider")
	ErrProviderNotConfig   = errors.New("provider not configured")
	ErrMissingCode         = errors.New("missing code")
	ErrEmailRequired       = errors.New("verified email required")
)
View Source
var (
	ErrSSHKeyName     = errors.New("ssh key: a name is required")
	ErrSSHKeyNotFound = errors.New("ssh key: no such key")

	// ErrNoUsableFleetKey is returned when nothing can reach the fleet. It is a
	// distinct error because the remedy is specific — mint one and whitelist it —
	// and reporting it as a generic failure sends an operator looking at hosts.
	ErrNoUsableFleetKey = errors.New("ssh key: this organization has no usable fleet key")
)
View Source
var ErrAccountLocked = errors.New("account temporarily locked")
View Source
var ErrEmailChangeInvalid = errors.New("email change token is invalid or expired")
View Source
var ErrEmailChangeRequiresVerification = errors.New("email changes must be confirmed via the verification link")
View Source
var ErrInvalidOAuthState = errors.New("invalid or expired oauth state")
View Source
var ErrInvalidToken = errors.New("invalid token")
View Source
var ErrPasswordPolicy = errors.New("password does not meet policy")
View Source
var ErrPolicyUnknownKind = errors.New("policy: unknown policy kind")

ErrPolicyUnknownKind is a sentinel so handlers can answer 422 rather than 500 for a kind that does not exist. A refusal the caller caused is not a server fault, and reporting it as one puts an operator's typo on the 5xx dashboard.

View Source
var ErrTenantNotProvisioned = errors.New("invitation: this organization has no tenant database yet")

ErrTenantNotProvisioned means the organization has no tenant database yet, so there is nothing for a new member to be admitted into. It is distinct from ErrTenantUnavailable, which means a provisioned tenant could not be reached: one is "not yet", the other is "not right now", and only the second is worth paging anybody about.

View Source
var ErrUnauthorized = errors.New("unauthorized")

Functions

func BeeLiveness

func BeeLiveness(b *tenant.Bee) string

BeeLiveness is the read side of the declared-absence model, exposed so that handlers and any future sweep agree on what "offline" means.

func CeilingFor

func CeilingFor(base int, trust SourceTrust, coarse bool) int

CeilingFor scales the unproven-organization allowance.

Two INDEPENDENT axes, which is easy to conflate and wrong to:

  • Trust: how much the address tells you about who is asking.
  • Coarseness: how many distinct actors share the bucket.

A SASE egress is coarse AND high-trust — thousands of one company's staff behind a handful of addresses. A cloud host is coarse and low-trust. A residential line is fine-grained and middling. Scaling on trust alone gives a bank's entire workforce the same allowance as one home broadband connection, which is the failure this function exists to avoid.

The multipliers are the knob most likely to need tuning against real traffic, which is why the ceiling ships in shadow mode.

func HashToken

func HashToken(raw string) string

func IsFirewallValidation

func IsFirewallValidation(err error) bool

IsFirewallValidation reports whether an error is something the operator typed, so a handler can answer 422 rather than 500. Every validation error is a deliberate refusal, and a refusal reported as a server fault puts an operator's own typo on a 5xx dashboard.

func MapProxmoxResources

func MapProxmoxResources(rs proxmox.ClusterResources) []tenant.ResourceRow

MapProxmoxResources translates Proxmox's inventory into the neutral shape.

Exported and free of any client, so the mapping is testable against literal API rows — which is where the units matter. Proxmox reports memory and disk in BYTES and CPU as a core count in MaxCPU (the `CPU` field is a utilisation ratio, not a count, and using it would report every idle node as having no cores).

func NewEnrolmentToken

func NewEnrolmentToken() (raw, hash string, err error)

NewEnrolmentToken mints a token and the hash to store for it.

Package-level as well as a method because the onboarding WORKER needs one and has no FleetService — and nothing about minting depends on service state. The method is kept so existing callers are unchanged.

func ObservationFresh

func ObservationFresh(b *tenant.Bee, now time.Time) bool

ObservationFresh reports whether a bee's membership observation is recent enough to gate on.

Types

type APIKeyService

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

func NewAPIKeyService

func NewAPIKeyService(keys repo.APIKeyRepo) *APIKeyService

func (*APIKeyService) Authenticate

func (s *APIKeyService) Authenticate(ctx context.Context, public, secret string, wantScopes ...string) (*Authenticated, error)

func (*APIKeyService) Mint

func (s *APIKeyService) Mint(ctx context.Context, in MintInput) (*MintResult, error)

func (*APIKeyService) Revoke

func (s *APIKeyService) Revoke(ctx context.Context, id uuid.UUID) error

Authenticate verifies a presented credential and confirms it is admitted on the surface being called.

wantScope is the surface, not a preference: the agent endpoints pass APIKeyScopeAgent and everything else passes the org/user pair, and the exclusion runs both ways. An org key must not reach the agent surface (a human or an automation forging an agent) and an agent key must not reach the org surface (a compromised hypervisor turning its credential on the rest of the tenant's API). Revoke retires a key by id.

Idempotent, inherited from the repository: revoking an already-revoked key is the state the caller wanted, and erroring would make a retried request look like a failure. Takes no scope or organization, because the id already names exactly one key — the authorization to revoke it belongs to whoever resolved that id, not to this call.

type AcceptInvitationInput

type AcceptInvitationInput struct {
	Token     string
	Password  string
	FirstName string
	LastName  string
}

type AcceptInvitationResult

type AcceptInvitationResult struct {
	Email        string
	Organization string
	// AccountCreated distinguishes the two endings the UI has to explain: a new
	// account that now has a password, and an existing account that just gained
	// a membership.
	AccountCreated bool
}

type AccessRuleResolver

type AccessRuleResolver interface {
	CheckAccessFor(ctx context.Context, email string, purpose repo.AccessPurpose) error
}

AccessRuleResolver is the area boundary between the gate and the rules layer. The gate never reads auth_access_rules itself and never re-implements the email-beats-domain precedence; if it did, the rule an operator wrote and the rule the gate enforced could silently drift apart.

type AccountDeletionService

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

AccountDeletionService owns the two irreversible-looking operations: deleting a person, and deleting an organization.

Both are SOFT deletes, for different reasons that happen to point the same way — see Organization.DeletedAt and User.DeletedAt. Neither destroys key material or a database; purging is a separate operator step, which is what leaves a way back from a mistake made at three in the morning.

func NewAccountDeletionService

func NewAccountDeletionService(db *gorm.DB) *AccountDeletionService

func (*AccountDeletionService) DeleteAccount

func (s *AccountDeletionService) DeleteAccount(ctx context.Context, userID uuid.UUID, now time.Time) error

DeleteAccount soft-deletes a user.

It refuses while the account is the sole owner of any organization. The way out is to promote another owner or delete the organization — both deliberate acts, and both leaving the tenant administrable either way.

One transaction: the checks and the write must not be separated, or an ownership change landing between them lets a sole owner delete themselves anyway.

func (*AccountDeletionService) DeleteOrganization

func (s *AccountDeletionService) DeleteOrganization(ctx context.Context, orgID uuid.UUID, confirmName string, now time.Time) error

DeleteOrganization soft-deletes an organization.

confirmName must equal the organization's name exactly. Not decoration: this is the single call that takes a customer's entire tenant out of service, and typing the name is the difference between meaning it and clicking the wrong row. Compared case-sensitively after trimming, because "acme" and "Acme" may be two different organizations.

func (*AccountDeletionService) SoleOwnerships

func (s *AccountDeletionService) SoleOwnerships(ctx context.Context, userID uuid.UUID) ([]control.Organization, error)

SoleOwnerships lists the organizations where this account is the ONLY owner.

Returned rather than merely counted so the caller can name them: "you are the only owner of Acme and Foo" is actionable, "you cannot delete your account" is not.

type AdmissionDecision

type AdmissionDecision struct {
	RegistrableDomain string
	UnprovenOrgs      int64
	Ceiling           int
}

AdmissionDecision is populated on the allow path AND on the ErrUnprovenOrgCeiling path, so the denial can be logged with the numbers that produced it.

type AdmissionInput

type AdmissionInput struct {
	Email string
}

AdmissionInput is the identity asking to create an organization. Registration passes the submitted email; org bootstrap passes user.Email.

type AdmissionService

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

AdmissionService is the single gate in front of both org-creation paths. It holds no *gorm.DB: the transaction is handed to Admit, so the gate can be constructed at wiring time with dependencies that are not open yet.

func NewAdmissionService

func NewAdmissionService(rules AccessRuleResolver, limits OrgBootstrapLimits) *AdmissionService

func (*AdmissionService) Admit

Admit is THE GATE. It MUST be called on the transaction that will create the organization, before any row is inserted and before any provisioning job is enqueued — creating a Postgres DATABASE and ROLE for a caller who is then refused leaves an orphan nothing ever cleans up.

Evaluation order: registrable domain, create_org access rules, the unproven-organization ceiling, the total organization quota, the hourly creation rate. On success the caller MUST write decision.RegistrableDomain to organizations.registrable_domain in this same transaction, or the org it creates is invisible to every future ceiling check.

func (*AdmissionService) EnsureConfigured

func (s *AdmissionService) EnsureConfigured() error

EnsureConfigured lets a caller refuse before it opens a transaction or reads anything. It is nil-receiver safe on purpose: api.Build constructs handlers with nil dependencies, and a nil gate must produce an error rather than a panic there — and a refusal rather than an allow everywhere else.

type AgentKeyMinter

type AgentKeyMinter interface {
	Mint(ctx context.Context, in MintInput) (*MintResult, error)
	// Revoke retires a credential a re-enrolment has superseded. Without it the
	// displaced key stays valid in the control database for the life of the
	// organization, with nothing pointing at it and nothing able to find it.
	Revoke(ctx context.Context, id uuid.UUID) error
	Authenticate(ctx context.Context, public, secret string, wantScopes ...string) (*Authenticated, error)
}

AgentKeyMinter issues the agent-scoped credential a bee authenticates with. Control-side, because that credential is what resolves the tenant.

type AuthClaims

type AuthClaims struct {
	OrgID       string   `json:"org_id,omitempty"`
	Type        string   `json:"typ"`
	AMR         []string `json:"amr,omitempty"`
	MFA         bool     `json:"mfa,omitempty"`
	MFAEnabled  bool     `json:"mfa_enabled,omitempty"`
	ActorID     string   `json:"act_id,omitempty"`
	Impersonate bool     `json:"imp,omitempty"`
	jwt.RegisteredClaims
}

type Authenticated

type Authenticated struct {
	Key *control.APIKey
}

Authenticated is what a verified credential establishes.

type BeeSession

type BeeSession struct {
	Bee            *tenant.Bee
	OrganizationID uuid.UUID
	APIKeyID       uuid.UUID
}

BeeSession is an authenticated bee together with the tenancy it was resolved through.

The organization is carried explicitly rather than read off the bee, because a tenant row has no organization column — the database IS the organization. Everything downstream needs it to reopen that database, so losing it here would mean re-authenticating to get it back.

type BeginLoginResult

type BeginLoginResult struct {
	Options   *protocol.CredentialAssertion
	Challenge string
}

type BeginRegistrationResult

type BeginRegistrationResult struct {
	Options   *protocol.CredentialCreation
	Challenge string
}

type ChangePasswordInput

type ChangePasswordInput struct {
	CurrentPassword string
	NewPassword     string
	OrgID           uuid.UUID
}

type ConfiguredSignInAlternatives

type ConfiguredSignInAlternatives struct {
	// Providers are the (name, id, secret) triples the social service handles.
	Providers []SocialProviderConfig
	Passkeys  PasskeyCounter
}

ConfiguredSignInAlternatives answers from the running configuration and the acting user's own credentials.

func (ConfiguredSignInAlternatives) Available

func (c ConfiguredSignInAlternatives) Available(
	ctx context.Context, actingUserID uuid.UUID,
) (bool, string, error)

Available checks the installation first, then the caller.

The order is deliberate. A configured provider is a way in for EVERYONE, which is the thing actually being protected. The acting user's passkey is weaker — it only guarantees that one person can get back in — but it is enough to make the change reversible by the person making it, which is the difference between an inconvenience and an outage.

type ConfirmResult

type ConfirmResult struct {
	Email string
	// AlreadyVerified distinguishes a second click on the same link from the
	// first. Both are success; the UI says something different for each.
	AlreadyVerified bool
	// TenantProvisioning reports whether provisioning was actually enqueued.
	TenantProvisioning bool
}

ConfirmResult tells the caller what happened without handing back a session.

type DirectEmailSender

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

DirectEmailSender sends synchronously over SMTP, with no retry.

Kept for development and tests, where a queue and a worker are more ceremony than the situation deserves. Production wiring should use QueuedEmailSender: a synchronous send inside a request means a slow mail server becomes a slow API, and a failed one is simply lost.

func NewDirectEmailSender

func NewDirectEmailSender(svc *EmailService) *DirectEmailSender

func (*DirectEmailSender) Send

func (d *DirectEmailSender) Send(_ context.Context, to, subject, htmlBody string) error

type EffectiveResult

type EffectiveResult struct {
	Kind    control.PolicyKind   `json:"kind"`
	Enabled bool                 `json:"enabled"`
	Config  control.PolicyConfig `json:"config"`
}

type EmailSender

type EmailSender interface {
	Send(ctx context.Context, to, subject, htmlBody string) error
}

EmailSender is what the services depend on. They render a message and hand it over; whether it goes straight to SMTP or onto a queue is not their business.

The seam exists because every caller previously spawned its own goroutine, logged whatever came back, and dropped the message — four copies of the same fire-and-forget mistake. Behind an interface there is one delivery policy, and changing it does not mean editing four services.

type EmailService

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

func NewEmailService

func NewEmailService(host string, port int, user, password, from string) *EmailService

func (*EmailService) Send

func (s *EmailService) Send(to, subject, htmlBody string) error

type EmailVerificationService

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

EmailVerificationService issues and redeems the proof that a registrant can read the mailbox they signed up with.

Redeeming is what releases tenant provisioning. Registration deliberately does not provision: a CREATE DATABASE and CREATE ROLE per unverified signup is a resource an unauthenticated caller can mint, and before this split every registration minted one before anyone had read a single email.

func NewEmailVerificationService

func NewEmailVerificationService(
	tokens repo.EmailVerificationRepo,
	users repo.UserRepo,
	memberships repo.MembershipRepo,
	email EmailSender,
	jobs workflow.Enqueuer,
	audit repo.AuthAuditRepo,
	baseURL string,
) *EmailVerificationService

func (*EmailVerificationService) Confirm

func (s *EmailVerificationService) Confirm(ctx context.Context, rawToken string) (*ConfirmResult, error)

Confirm redeems a token: it marks the address verified and releases tenant provisioning. It issues no session — a confirmed registrant signs in through the ordinary login path, so that every session in the system is minted by one piece of code with one set of checks.

func (*EmailVerificationService) Issue

Issue mints a token for a user and mails the link.

It returns an error, and registration propagates it. That is deliberate: if the token cannot be written, the account exists with no way to ever confirm it, and reporting success would leave the registrant permanently unable to sign in with no indication why.

func (*EmailVerificationService) Resend

func (s *EmailVerificationService) Resend(ctx context.Context, emailAddr string) error

Resend issues a fresh link. It reports success unconditionally.

Every branch below returns nil: unknown address, inactive user, already verified, and throttled all look identical from outside. This endpoint is unauthenticated and takes an arbitrary email, so any branch that answered differently would be a membership oracle — "is this address registered here, and has it been confirmed" — answerable by anyone.

type EnrolInput

type EnrolInput struct {
	EnrolmentToken string
	MachineID      string
	Hostname       string
	AgentVersion   string

	// PeerAddress is where the enrolment request came from. See
	// repo.preferredAddress: an agent knows its hostname and not how the control
	// plane reaches it, and the address of the request that just arrived is the
	// one candidate that is reachable by demonstration.
	PeerAddress string
}

type EnrolResult

type EnrolResult struct {
	Bee            *tenant.Bee
	OrganizationID uuid.UUID

	// The plaintext halves of the agent credential. They exist here and nowhere
	// else: only hashes are stored, so a key that is lost is replaced rather than
	// recovered.
	KeyPublic string
	KeySecret string
}

type EnrolmentRedeemer

type EnrolmentRedeemer interface {
	RedeemToken(ctx context.Context, tokenHash string, now time.Time) (uuid.UUID, error)
}

EnrolmentRedeemer consumes an enrolment token and reports which organization it admits a host into. Control-side: the caller is unauthenticated and has no tenant yet.

type EstateService

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

EstateService owns everything a request needs in order to touch an estate or a credential: resolving the caller's tenant database, deriving the key that seals credential material, and keeping the two records consistent with each other.

It exists because the repositories are deliberately narrow. They enforce the rules that must hold for a single row — AAD binding, scope canonicalisation, fingerprint over the identifying component — and know nothing about which tenant is asking or where the key came from. That is this layer's job.

func NewEstateService

func NewEstateService(tenants TenantResolver, masterKey []byte, scopes repo.ProviderScopeRegistry) (*EstateService, error)

func (*EstateService) Delete

func (s *EstateService) Delete(ctx context.Context, orgID, id uuid.UUID) error

Delete removes the estate and the credential it was bound to.

Both, deliberately. The binding is 1:1, so deleting only the estate would leave a sealed credential nothing references — unreachable through the API, invisible in any listing, and still a live secret for whatever provider it belongs to. The foreign key is ON DELETE RESTRICT, so the order is forced.

func (*EstateService) Get

func (s *EstateService) Get(ctx context.Context, orgID, id uuid.UUID) (*EstateView, error)

func (*EstateService) List

func (s *EstateService) List(ctx context.Context, orgID uuid.UUID) ([]EstateView, error)

func (*EstateService) Register

Register creates the credential and the estate that binds it, in one transaction.

Order is forced: Estate.CredentialID is NOT NULL, so the credential must exist first. That makes a partial failure possible — a credential with no estate is an orphaned secret nobody can see or reach — which is exactly why this runs in a transaction rather than as two calls from a handler.

func (*EstateService) RotateCredential

func (s *EstateService) RotateCredential(ctx context.Context, orgID, estateID uuid.UUID, identity string, secret repo.Secret) (*EstateView, error)

RotateCredential replaces the sealed material on an estate's credential in place, keeping the binding and the estate untouched.

In place rather than create-and-swap because the binding is 1:1: a second credential for one estate is unrepresentable, so a rotation that created one could not be committed. The fingerprint changes with the material, which is how an operator confirms the rotation actually took.

type EstateView

type EstateView struct {
	ID             uuid.UUID
	Name           string
	Provider       string
	Scope          map[string]string
	ScopeVersion   int
	CredentialID   uuid.UUID
	CredentialName string
	CredentialKind string
	Fingerprint    string
}

EstateView is what leaves this service. It never carries sealed material: nothing above this layer has a reason to see a credential's plaintext, and a field that does not exist cannot be serialised into a response by accident.

type FeatureFlagService

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

FeatureFlagService resolves flags and is the only place a write is validated against the registry.

func NewFeatureFlagService

func NewFeatureFlagService(flags repo.FeatureFlagRepo) *FeatureFlagService

func (*FeatureFlagService) ClearOrg

func (s *FeatureFlagService) ClearOrg(ctx context.Context, orgID uuid.UUID, key string) error

func (*FeatureFlagService) ClearPlatform

func (s *FeatureFlagService) ClearPlatform(ctx context.Context, key string) error

ClearPlatform and ClearOrg drop a row so the level below decides again.

func (*FeatureFlagService) Effective

func (s *FeatureFlagService) Effective(
	ctx context.Context, orgID uuid.UUID, key string,
) (*control.EffectiveFlag, error)

Effective resolves one flag and says which level decided it.

func (*FeatureFlagService) Enabled

func (s *FeatureFlagService) Enabled(ctx context.Context, orgID uuid.UUID, key string) (bool, error)

Enabled is the one call the rest of the codebase should need.

It takes the organization because almost every caller has one; pass uuid.Nil on the platform surface. An unregistered key returns the error rather than false: a caller asking about a flag that does not exist has a bug, and answering "off" would hide it behind behaviour that looks deliberate.

func (*FeatureFlagService) ListEffective

func (s *FeatureFlagService) ListEffective(
	ctx context.Context, orgID uuid.UUID,
) ([]control.EffectiveFlag, error)

ListEffective resolves every declared flag for one organization.

Two queries rather than one per flag: the registry is small and fully known, so the rows are fetched in bulk and matched in memory. A per-flag round trip would make a settings screen's cost grow with the registry.

func (*FeatureFlagService) ListPublicEffective

func (s *FeatureFlagService) ListPublicEffective(ctx context.Context) ([]control.EffectiveFlag, error)

ListPublicEffective resolves the flags an unauthenticated caller may read.

NO ORGANIZATION IS CONSULTED, and not because the caller happens not to have one — because a public flag is platform-scoped by construction, so there is no organization row that could apply. Passing uuid.Nil would reach the same answer today; naming it here means a future tenant-scoped public flag fails to compile a decision rather than silently resolving to whatever the platform happens to say.

func (*FeatureFlagService) OverridesFor

func (s *FeatureFlagService) OverridesFor(
	ctx context.Context, key string,
) ([]repo.FlagOverride, error)

OverridesFor lists the organizations that have set this flag themselves, which is the question a platform operator asks before changing the default.

func (*FeatureFlagService) PlatformEnabled

func (s *FeatureFlagService) PlatformEnabled(ctx context.Context, key string) (bool, error)

PlatformEnabled answers one platform-scoped flag with no organization in play.

For the server's own gates — the sign-in handler asking whether it is allowed to run at all — where there is no tenant yet and inventing uuid.Nil at each call site invites somebody passing a real one and getting a per-tenant answer to an installation-wide question.

func (*FeatureFlagService) SetOrg

func (s *FeatureFlagService) SetOrg(
	ctx context.Context, orgID uuid.UUID, key string, value bool, by uuid.UUID,
) error

SetOrg stores one organization's override.

REFUSED FOR A PLATFORM-SCOPED FLAG, with an error that says so rather than "unknown". Naming a real flag at the wrong level and mistyping one are different mistakes with different fixes, and collapsing them sends an operator to check a spelling that was right.

func (*FeatureFlagService) SetPlatform

func (s *FeatureFlagService) SetPlatform(
	ctx context.Context, key string, value bool, by uuid.UUID, acknowledgeLockout bool,
) error

SetPlatform stores the installation-wide value.

acknowledgeLockout is the operator saying, explicitly, that they intend to disable a way of signing in with nothing else configured. It is ignored for every flag that does not gate sign-in.

func (*FeatureFlagService) WithSignInAlternatives

func (s *FeatureFlagService) WithSignInAlternatives(a SignInAlternatives) *FeatureFlagService

WithSignInAlternatives wires the lockout guard.

Separate from the constructor so every existing caller and test keeps working unchanged, and so the guard's absence is visible at the wiring site rather than hidden in a nil argument.

type FinishLoginInput

type FinishLoginInput struct {
	Challenge string
	Response  *protocol.ParsedCredentialAssertionData
	IP        string
	UA        string
}

type FinishRegistrationInput

type FinishRegistrationInput struct {
	Challenge string
	Response  *protocol.ParsedCredentialCreationData
	Name      string
}

type FirewallService

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

FirewallService owns authoring: rulesets, the rules inside them, and which fleets they are bound to.

It does NOT publish. Turning authored rules into a revision a bee converges on is a separate step with a separate failure mode — an edit that is merely saved changes nothing on any host, which is what makes it safe to edit in the middle of the day.

func NewFirewallService

func NewFirewallService(tenants TenantResolver) *FirewallService

func (*FirewallService) AddSetElement

func (s *FirewallService) AddSetElement(
	ctx context.Context, orgID, setID uuid.UUID, value, comment, label string,
) (*tenant.FirewallSetElement, error)

AddSetElement puts one address or prefix into a set.

Validated through the SET, not on its own, because whether 10.0.0.0/8 is acceptable depends on the family the set was declared with — an ipv6 element in an ipv4 set renders a declaration nft rejects, and nft rejects the whole ruleset over one bad line.

func (*FirewallService) Bind

func (s *FirewallService) Bind(
	ctx context.Context, orgID uuid.UUID, hiveID, swarmID *uuid.UUID,
	rulesetID uuid.UUID, priority int, enabled bool,
) (*tenant.FirewallBinding, error)

Bind attaches a ruleset to a fleet, or to the whole organization when hiveID is nil.

The org-wide case is what covers a host between fleets. It is also how a base ruleset stops being copied per fleet, which is the duplication the whole structure exists to avoid.

func (*FirewallService) CreateRule

func (s *FirewallService) CreateRule(ctx context.Context, orgID, rulesetID uuid.UUID, in RuleInput) (*tenant.FirewallRule, error)

func (*FirewallService) CreateRuleset

func (s *FirewallService) CreateRuleset(ctx context.Context, orgID uuid.UUID, name, description string) (*tenant.FirewallRuleset, error)

func (*FirewallService) CreateSet

func (s *FirewallService) CreateSet(
	ctx context.Context, orgID uuid.UUID, name, family, description, purpose string,
) (*tenant.FirewallSet, error)

CreateSet makes a named list rules can point at.

Purpose defaults to general — the harmless one — because a set created without thought must never widen the `from=` restriction that onboarding installs on the fleet key. Asking for management is a deliberate act.

func (*FirewallService) DeleteRule

func (s *FirewallService) DeleteRule(ctx context.Context, orgID, id uuid.UUID) error

func (*FirewallService) DeleteRuleset

func (s *FirewallService) DeleteRuleset(ctx context.Context, orgID, id uuid.UUID) error

func (*FirewallService) DeleteSet

func (s *FirewallService) DeleteSet(ctx context.Context, orgID, id uuid.UUID) error

func (*FirewallService) GetSet

func (s *FirewallService) GetSet(ctx context.Context, orgID, id uuid.UUID) (*tenant.FirewallSet, error)

func (*FirewallService) ListBindings

func (s *FirewallService) ListBindings(ctx context.Context, orgID uuid.UUID) ([]tenant.FirewallBinding, error)

func (*FirewallService) ListRules

func (s *FirewallService) ListRules(ctx context.Context, orgID, rulesetID uuid.UUID) ([]tenant.FirewallRule, error)

func (*FirewallService) ListRulesets

func (s *FirewallService) ListRulesets(ctx context.Context, orgID uuid.UUID) ([]tenant.FirewallRuleset, error)

func (*FirewallService) ListSets

func (s *FirewallService) ListSets(ctx context.Context, orgID uuid.UUID) ([]tenant.FirewallSet, error)

func (*FirewallService) ManagementSets

func (s *FirewallService) ManagementSets(ctx context.Context, orgID uuid.UUID) ([]tenant.FirewallSet, error)

ManagementSets returns the sets that feed the authorized_keys `from=` clause.

Purpose is read from the set rather than inferred from the ports a rule opens. Corosync peers are permitted on 22 so pvecm can distribute cluster configuration, so inferring would list every hypervisor in the fleet and make a stolen control-plane key usable from any of them.

func (*FirewallService) Preview

func (s *FirewallService) Preview(
	ctx context.Context, orgID uuid.UUID, hiveID, swarmID *uuid.UUID,
) ([]tenant.FirewallRule, error)

Preview returns the flattened, ordered rules a fleet would receive.

It exists because composition is invisible from any single screen: an operator editing "base" cannot see it interleaving with "dc1", and binding priority silently outranks the positions they were just looking at. Answering "what does hv5 actually get" is the difference between authoring a policy and hoping about one.

A nil hiveID answers for a host that belongs to no fleet. A nil swarmID means "not in a swarm, or we do not know which" — both answer with hive and org scope, which is the same thing a real host in no swarm receives.

func (*FirewallService) RemoveSetElement

func (s *FirewallService) RemoveSetElement(ctx context.Context, orgID, setID, elementID uuid.UUID) error

func (*FirewallService) Unbind

func (s *FirewallService) Unbind(ctx context.Context, orgID, id uuid.UUID) error

func (*FirewallService) UpdateBinding

func (s *FirewallService) UpdateBinding(ctx context.Context, orgID, id uuid.UUID, priority int, enabled bool) error

func (*FirewallService) UpdateBindingScope

func (s *FirewallService) UpdateBindingScope(
	ctx context.Context, orgID, id uuid.UUID, priority int, enabled bool,
	scope *repo.BindingScopeChange,
) error

UpdateBindingScope updates a binding and, when scope is non-nil, moves it.

The scope is validated with the SAME rules the create path uses, because a binding moved into an invalid scope is indistinguishable from one created there. In particular a swarm without its hive is refused: swarm names are unique only within a hive, and BindingsForScope matches swarm bindings on the swarm id ALONE — safe only because the pair is checked here. Accepting the half would let a binding written for one cluster's "prod" group resolve against another cluster's.

func (*FirewallService) UpdateRule

func (s *FirewallService) UpdateRule(ctx context.Context, orgID, id uuid.UUID, in RuleInput) (*tenant.FirewallRule, error)

func (*FirewallService) UpdateRuleset

func (s *FirewallService) UpdateRuleset(ctx context.Context, orgID, id uuid.UUID, name, description string) (*tenant.FirewallRuleset, error)

func (*FirewallService) UpdateSet

func (s *FirewallService) UpdateSet(
	ctx context.Context, orgID, id uuid.UUID, name, description string,
) (*tenant.FirewallSet, error)

type FleetService

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

FleetService owns the two-sided shape the fleet now has: control answers which tenant is calling, and every fleet row lives in that tenant's database.

It therefore holds no FleetRepo. One cannot exist before a request arrives, because which database it binds to is not known until the caller is authenticated.

func NewFleetService

func NewFleetService(tenants TenantResolver, enrolment EnrolmentRedeemer, keys AgentKeyMinter, audit repo.AuthAuditRepo) (*FleetService, error)

NewFleetService accepts missing dependencies and denies at request time rather than refusing to build.

That is the convention here — see Deps.Admission — and it exists because api.Build is exercised with partially-populated Deps in the router tests, which assert routing and auth properties and have no interest in a tenant manager. Refusing here would make an unrelated test suite carry a database.

Denying is not the same as ignoring. Every entry point below checks before it acts, so a genuinely misconfigured deployment answers 503 on the fleet surface and nothing else, instead of panicking on the first agent heartbeat.

func (*FleetService) AppendLogs

func (s *FleetService) AppendLogs(ctx context.Context, sess *BeeSession, taskID uuid.UUID, lines []LogLine) (int, error)

AppendLogs stores a batch of streamed output.

Lines are truncated rather than rejected: losing the tail of one enormous line is a far better outcome during a rotation than discarding a batch that also carried the error message explaining what went wrong.

func (*FleetService) Authenticate

func (s *FleetService) Authenticate(ctx context.Context, public, secret string) (*BeeSession, error)

Authenticate resolves a bee from the agent credential it presents.

Two steps, in this order, because the first is what makes the second possible: the key is verified in the control database, which yields the organization, and only then can that organization's database be opened to find the bee.

func (*FleetService) DesiredFirewallFor

func (s *FleetService) DesiredFirewallFor(
	ctx context.Context, sess *BeeSession,
) (*tenant.FirewallSnapshot, error)

DesiredFirewallFor returns what the host running this bee should be enforcing, or nil for HOLD.

Nil is a real answer with a specific meaning, and callers must not flatten it into an empty ruleset: a bee whose machine is between fleets, or whose hive has published nothing, is told to keep what it has. Flushing there would strip a mid-migration host of its management access at the moment it is least reachable.

func (*FleetService) DesiredMembershipFor

func (s *FleetService) DesiredMembershipFor(
	ctx context.Context, sess *BeeSession,
) (*tenant.MembershipDesired, error)

DesiredMembershipFor returns the composition the host running this bee should converge on, or nil when there is nothing to converge on.

NIL MEANS HOLD, exactly as an absent firewall does. It is returned for a machine in no hive and for a hive that has published nothing, and neither is "you should be in no cluster" — reading it that way would make an unpublished hive dissolve itself node by node the first time every agent polled.

The composition is re-rendered here rather than read from a stored body, and the revision comes from the hive row. A published revision whose rendered digest no longer matches means a write changed membership without publishing; that is repaired by publishing rather than served, because serving a body whose digest disagrees with the one the heartbeat advertised would make the agent converge and immediately be judged diverged again — forever.

func (*FleetService) Enrol

func (s *FleetService) Enrol(ctx context.Context, in EnrolInput) (*EnrolResult, error)

func (*FleetService) FinaliseDepartures

func (s *FleetService) FinaliseDepartures(ctx context.Context, sess *BeeSession) error

FinaliseDepartures clears the machines this bee's hive has actually dropped.

Driven from the heartbeat because that is when the evidence arrives: a quorate member reporting a composition that no longer lists a departing node is the authority. The departing node itself is not — after `pvecm delnode` it still believes it is a member, which is precisely why it must not be powered back on.

func (*FleetService) Heartbeat

func (s *FleetService) Heartbeat(ctx context.Context, in HeartbeatInput) (*HeartbeatResult, error)

Heartbeat records liveness, applies any declared absence, and returns this bee's one outstanding task — waiting for one to appear if asked.

Liveness and task delivery share a request on purpose. Two endpoints would mean two ways for a bee to be "seen", and a bee parked on a long poll would have to heartbeat separately to avoid looking dead — which is precisely the confusion this design exists to remove.

func (*FleetService) NewEnrolmentToken

func (s *FleetService) NewEnrolmentToken() (raw, hash string, err error)

NewEnrolmentToken returns a token to hand to an operator and the hash to store. The raw value exists only in this return and in whatever the operator pastes onto a host.

func (*FleetService) PublishMembership

func (s *FleetService) PublishMembership(ctx context.Context, db *gorm.DB, hiveID uuid.UUID) error

PublishMembership commits a hive's composition, and is safe to call when nothing changed.

Exposed on the service because every write that changes membership intent has to call it, and those writes live in handlers and jobs that should not be reaching into repo directly.

func (*FleetService) RecordAppliedFirewall

func (s *FleetService) RecordAppliedFirewall(
	ctx context.Context, sess *BeeSession, revision int, digest string,
) error

RecordAppliedFirewall stores what a bee reports it is enforcing.

Reported rather than assumed, because a publish only says what should be running. Marking the fleet converged when an operator clicks Publish would report a policy as enforced on every host that happened to be switched off.

func (*FleetService) RecordAppliedMembership

func (s *FleetService) RecordAppliedMembership(
	ctx context.Context, sess *BeeSession, revision int, digest string,
) error

RecordAppliedMembership stores the composition a bee reports it has converged on.

func (*FleetService) RecordObservedGuests

func (s *FleetService) RecordObservedGuests(
	ctx context.Context, sess *BeeSession, guests *int, inACluster bool,
) error

RecordObservedGuests stores what a host says it is running.

Written ONLY for a machine in no cluster, which is the gap: the capacity sync observes per cluster, so a fleetless machine is never observed by it at all, and fleetless is exactly the state a machine is in when it is about to join one. Restricting it to that case keeps a single writer per row rather than letting an agent report and a sync overwrite in turn, which would flap on every tick where the two disagree.

The condition lives in the UPDATE rather than in a read-then-write, so a machine that joined a cluster between the two cannot have its swept-up observation land afterwards.

func (*FleetService) RecordObservedMembership

func (s *FleetService) RecordObservedMembership(
	ctx context.Context, sess *BeeSession, obs *tenant.Observed,
	fingerprint string, hostKeys []string, rootKey string, secrets *int, carriesConfig *bool,
) error

RecordObservedMembership stores what a host says about the cluster it can see.

UNCONDITIONAL, unlike the observed guest count, which is written only for a fleetless machine to keep a single writer per row. There is no competing writer here: the capacity sync records what the Proxmox API says about a CLUSTER, and this records what one HOST says about itself. They are allowed to disagree, and a disagreement between them is the useful signal — it is how a node that has silently fallen out of corosync while still answering its API becomes visible.

An agent that did not report is distinguished from one reporting a standalone host by observed_membership_at, which is why the caller passes a pointer: an older agent omits the field entirely, and reading that as "standalone" would invent an observation nobody made and hand it to a loop that acts on it.

func (*FleetService) RecordObservedNodeName

func (s *FleetService) RecordObservedNodeName(
	ctx context.Context, sess *BeeSession, node string,
) error

RecordObservedNodeName stores the Proxmox node name a host reports for itself.

A machine has no node name until it is in a cluster, so an arrival records membership without one and the host supplies it once it has joined. Written only when the row has none — see the repo function for why a rename must not come from a heartbeat.

func (*FleetService) Report

func (s *FleetService) Report(ctx context.Context, in ReportInput) (*tenant.FleetTask, error)

type HeartbeatInput

type HeartbeatInput struct {
	// Session rather than a bare bee: a tenant row carries no organization, so
	// without it there is nothing to reopen the right database with.
	Session      *BeeSession
	AgentVersion string
	// AgentDigest is the sha256 of the binary this agent is running — what the
	// upgrade decision is actually made on. See tenant.Bee.AgentDigest.
	AgentDigest string

	// UnavailableFor is the bee declaring, before it acts, roughly how long it
	// will be gone. Zero leaves any standing declaration untouched.
	UnavailableFor    time.Duration
	UnavailableReason string
	// Back withdraws a standing declaration: the bee has returned.
	Back bool

	// Wait asks the handler to hold the request open until a task appears.
	Wait time.Duration
}

type HeartbeatResult

type HeartbeatResult struct {
	Task   *tenant.FleetTask
	Status string
}

type ImpersonationService

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

func NewImpersonationService

func NewImpersonationService(
	users repo.UserRepo,
	memberships repo.MembershipRepo,
	grants repo.ImpersonationGrantRepo,
	sessions *TokenSessionService,
	audit repo.AuthAuditRepo,
) *ImpersonationService

func (*ImpersonationService) End

func (s *ImpersonationService) End(ctx context.Context, accessJTI string, targetUserID, actorID, orgID uuid.UUID, ip, ua string) error

func (*ImpersonationService) Start

func (*ImpersonationService) ToggleSupport

func (s *ImpersonationService) ToggleSupport(ctx context.Context, in ToggleSupportInput) error

type InvitationPreview

type InvitationPreview struct {
	Email            string
	Organization     string
	Role             control.MemberRole
	NeedsAccount     bool
	NeedsAccountName bool
}

InvitationPreview is what the accept page needs in order to render before the invitee commits to anything.

type InvitationService

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

func NewInvitationService

func NewInvitationService(
	db *gorm.DB,
	invitations repo.InvitationRepo,
	users repo.UserRepo,
	memberships repo.MembershipRepo,
	orgs repo.OrganizationRepo,
	policies *PolicyService,
	accessRules AccessRuleResolver,
	email EmailSender,
	audit repo.AuthAuditRepo,
	baseURL string,
) *InvitationService

func (*InvitationService) Accept

Accept redeems an invitation. It issues NO session, on either branch.

A new invitee sets a password here and then signs in through /auth/login like anyone else; an existing account gains the membership and signs in as it already could. Keeping session minting on the login path alone means the checks that guard it — lockout, MFA, the confirmation gate — are written once and cannot be skipped by arriving through this door.

func (*InvitationService) Invite

Invite creates an invitation and mails the link.

The organization comes from the caller's principal, never from the request — the handler resolves it — so this method cannot be used to invite someone into a tenant the caller has no membership in.

func (*InvitationService) List

func (s *InvitationService) List(ctx context.Context, orgID uuid.UUID) ([]InvitationView, error)

func (*InvitationService) Preview

func (s *InvitationService) Preview(ctx context.Context, rawToken string) (*InvitationPreview, error)

Preview describes an invitation to whoever holds its token.

Possession of the token IS the authorization: it was mailed to the address it names. It reveals whether that address already has an account, which the page needs in order to ask for a password or to send them to sign in — and which a token holder is entitled to know about their own address.

func (*InvitationService) Revoke

func (s *InvitationService) Revoke(ctx context.Context, orgID, id, actorID uuid.UUID) error

type InvitationView

type InvitationView struct {
	ID        uuid.UUID
	Email     string
	Role      control.MemberRole
	ExpiresAt time.Time
	CreatedAt time.Time
	// Status is derived rather than stored: the row carries accepted_at,
	// revoked_at and expires_at, and exactly one reading of those is correct.
	// Deriving it in one place stops the API and the UI disagreeing about what
	// an expired-but-unaccepted invitation is.
	Status string
}

type InviteInput

type InviteInput struct {
	OrgID     uuid.UUID
	InviterID uuid.UUID
	Email     string
	Role      control.MemberRole
}

type IssueOpts

type IssueOpts struct {
	AMR         []string
	MFA         bool
	MFAEnabled  bool
	ActorID     string
	Impersonate bool
}

type IssueTrackedInput

type IssueTrackedInput struct {
	SubjectID   string
	SubjectKind string
	OrgID       string
	FamilyID    *uuid.UUID
	Opts        IssueOpts
	IPAddress   string
	UserAgent   string
	EventType   string
	ActorID     *uuid.UUID
	Metadata    map[string]any
}

type IssuedTokens

type IssuedTokens struct {
	TokenPair
	AccessJTI        string
	RefreshJTI       string
	AccessExpiresAt  time.Time
	RefreshExpiresAt time.Time
}

type LogLine

type LogLine struct {
	Seq      int64
	Stream   string
	Message  string
	LoggedAt time.Time
}

type LoginInput

type LoginInput struct {
	Email     string
	Password  string
	IPAddress string
	UserAgent string
}

type LoginResult

type LoginResult struct {
	User           *control.User
	OrganizationID string
	Organization   string
	Tokens         *IssuedTokens
	MFARequired    bool
	MFAToken       string
}

type LoginService

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

func NewLoginService

func NewLoginService(users repo.UserRepo, memberships repo.MembershipRepo, totps repo.UserTOTPRepo, tokens *TokenService, sessions *TokenSessionService, policies *PolicyService, challenges repo.AuthChallengeRepo, failures repo.AuthFailureRepo) *LoginService

func (*LoginService) Login

func (s *LoginService) Login(ctx context.Context, in LoginInput) (*LoginResult, error)

type MFAService

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

func NewMFAService

func NewMFAService(
	totps repo.UserTOTPRepo,
	passkeys repo.UserPasskeyRepo,
	users repo.UserRepo,
	encryptionKey string,
	issuerName string,
) *MFAService

func (*MFAService) DisableTOTP

func (s *MFAService) DisableTOTP(ctx context.Context, userID uuid.UUID) error

func (*MFAService) SetAudit

func (s *MFAService) SetAudit(r repo.AuthAuditRepo)

func (*MFAService) SetupTOTP

func (s *MFAService) SetupTOTP(ctx context.Context, userID uuid.UUID, email string) (*TOTPSetupResult, error)

func (*MFAService) UserHasMFA

func (s *MFAService) UserHasMFA(ctx context.Context, userID uuid.UUID) (bool, error)

func (*MFAService) ValidateTOTP

func (s *MFAService) ValidateTOTP(ctx context.Context, userID uuid.UUID, code string) error

func (*MFAService) VerifyAndActivateTOTP

func (s *MFAService) VerifyAndActivateTOTP(ctx context.Context, userID uuid.UUID, code string) error

type MeResult

type MeResult struct {
	User struct {
		ID        string `json:"id"`
		Email     string `json:"email"`
		FirstName string `json:"first_name"`
		LastName  string `json:"last_name"`
		Role      string `json:"role"`
		IsActive  bool   `json:"is_active"`
	}
	Organization struct {
		ID     string `json:"id"`
		Name   string `json:"name"`
		Status string `json:"status"`
	} `json:"organization"`
}

type MemberView

type MemberView struct {
	MembershipID   uuid.UUID
	UserID         uuid.UUID
	Email          string
	FirstName      string
	LastName       string
	Role           control.MemberRole
	SupportEnabled bool
}

type MembershipService

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

func NewMembershipService

func NewMembershipService(db *gorm.DB, memberships repo.MembershipRepo, audit repo.AuthAuditRepo) *MembershipService

func (*MembershipService) List

func (s *MembershipService) List(ctx context.Context, orgID uuid.UUID) ([]MemberView, error)

func (*MembershipService) Remove

func (*MembershipService) UpdateRole

func (s *MembershipService) UpdateRole(ctx context.Context, in UpdateRoleInput) error

type MintInput

type MintInput struct {
	Scope   string
	Name    string
	Purpose string
	OrgID   *uuid.UUID
	UserID  *uuid.UUID
	TTL     time.Duration
}

type MintResult

type MintResult struct {
	Key    *control.APIKey
	Public string
	Secret string
}

MintResult carries the plaintext halves. They exist here and nowhere else: only hashes are stored, so a key that is lost is replaced, never recovered.

type MyOrganizationResult

type MyOrganizationResult struct {
	ID        string `json:"id"`
	Name      string `json:"name"`
	Status    string `json:"status"`
	Role      string `json:"role"`
	IsDefault bool   `json:"is_default"`
}

type NetworkClassifier

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

NetworkClassifier decides from operator-supplied CIDR ranges alone. It cannot distinguish corporate from residential — that needs allocation data — so it reports everything unmatched as SourceResidential, which is the conservative choice: residential gets per-address bucketing and the ordinary ceiling, neither the relaxation corroboration would earn nor the tightening a datacenter gets.

func NewNetworkClassifier

func NewNetworkClassifier(datacenterCIDRs, anonymisedCIDRs, enterpriseCIDRs []string) *NetworkClassifier

NewNetworkClassifier builds a classifier. Unparseable entries are skipped rather than fatal: a typo in an operator's range list must not take registration down.

func (*NetworkClassifier) Classify

func (c *NetworkClassifier) Classify(in SourceInput) SourceKey

type OrgBootstrapLimits

type OrgBootstrapLimits struct {
	MaxTotalOrgs             int
	MaxNewOrgsPerHour        int
	MaxUnprovenOrgsPerDomain int
}

type OrgBootstrapService

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

func NewOrgBootstrapService

func NewOrgBootstrapService(db *gorm.DB, jobs workflow.Enqueuer, admission *AdmissionService) *OrgBootstrapService

func (*OrgBootstrapService) EnsureUserHasWorkspace

func (s *OrgBootstrapService) EnsureUserHasWorkspace(ctx context.Context, user *control.User) (*control.Membership, error)

type PasskeyCounter

type PasskeyCounter interface {
	ListByUserID(ctx context.Context, userID uuid.UUID) ([]control.UserPasskey, error)
}

PasskeyCounter is the slice of the passkey store this needs.

type PasskeyService

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

func NewPasskeyService

func NewPasskeyService(
	passkeys repo.UserPasskeyRepo,
	users repo.UserRepo,
	memberships repo.MembershipRepo,
	tokens *TokenService,
	sessions *TokenSessionService,
	jwtSecret string,
	rpID, rpOrigin, rpName string,
) (*PasskeyService, error)

func (*PasskeyService) BeginLogin

func (s *PasskeyService) BeginLogin(ctx context.Context) (*BeginLoginResult, error)

func (*PasskeyService) BeginRegistration

func (s *PasskeyService) BeginRegistration(ctx context.Context, userID uuid.UUID) (*BeginRegistrationResult, error)

func (*PasskeyService) Delete

func (s *PasskeyService) Delete(ctx context.Context, id, userID uuid.UUID) error

func (*PasskeyService) FinishLogin

func (*PasskeyService) FinishRegistration

func (s *PasskeyService) FinishRegistration(ctx context.Context, userID uuid.UUID, in FinishRegistrationInput) (*control.UserPasskey, error)

func (*PasskeyService) List

func (s *PasskeyService) List(ctx context.Context, userID uuid.UUID) ([]control.UserPasskey, error)

func (*PasskeyService) SetAudit

func (s *PasskeyService) SetAudit(r repo.AuthAuditRepo)

func (*PasskeyService) SetChallenges

func (s *PasskeyService) SetChallenges(r repo.AuthChallengeRepo)

type PasswordResetService

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

func NewPasswordResetService

func NewPasswordResetService(
	resets repo.PasswordResetRepo,
	users repo.UserRepo,
	email EmailSender,
	baseURL string,
	sessions *TokenSessionService,
	policies *PolicyService,
	memberships repo.MembershipRepo,
	audit repo.AuthAuditRepo,
) *PasswordResetService

func (*PasswordResetService) RequestReset

func (s *PasswordResetService) RequestReset(ctx context.Context, emailAddr string) error

func (*PasswordResetService) ResetPassword

func (s *PasswordResetService) ResetPassword(ctx context.Context, rawToken, newPassword string) error

type PolicyService

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

func NewPolicyService

func NewPolicyService(
	platform repo.PlatformPolicyRepo,
	org repo.OrgPolicyRepo,
	users repo.UserRepo,
	passkeys repo.UserPasskeyRepo,
) *PolicyService

func (*PolicyService) CheckUserCompliance

func (s *PolicyService) CheckUserCompliance(ctx context.Context, userID, orgID uuid.UUID) ([]PolicyViolation, error)

func (*PolicyService) DeleteOrgPolicy

func (s *PolicyService) DeleteOrgPolicy(ctx context.Context, orgID uuid.UUID, kind control.PolicyKind) error

func (*PolicyService) DeletePlatformPolicy

func (s *PolicyService) DeletePlatformPolicy(ctx context.Context, kind control.PolicyKind) error

func (*PolicyService) EffectivePolicy

func (s *PolicyService) EffectivePolicy(ctx context.Context, orgID uuid.UUID, kind control.PolicyKind) (*EffectiveResult, error)

EffectivePolicy merges the platform and organization rows for one policy kind.

A DISABLED row contributes nothing — neither its enabled bit nor its config. That second half was missing: the config was read from both rows unconditionally, so as long as ANY level had the kind switched on, a switched-off level's stale numbers went on tightening the result. A platform session_max_lifetime row with Enabled=false and MaxLifetimeMinutes=5 beat an enabled org row of 480, and every session in that organization expired after five minutes because of a policy an operator had explicitly turned off.

"Disabled" is the identity element of this merge, and the zero values already behave that way for every operator here — max() for a minimum length, min-of-positives for a lifetime ceiling, OR for the complexity flags. So gating the READ is the entire fix; nothing downstream had to change.

Note the merge can only ever TIGHTEN, which is why the defect was an availability and correctness problem rather than a privilege-escalation one. It is also why the restriction/permission split matters: restrictions merge with OR so an organization relying on its own strict setting does not lose it when the platform loosens.

func (*PolicyService) ListEffectivePolicies

func (s *PolicyService) ListEffectivePolicies(ctx context.Context, orgID uuid.UUID) ([]EffectiveResult, error)

func (*PolicyService) ListOrgPolicies

func (s *PolicyService) ListOrgPolicies(ctx context.Context, orgID uuid.UUID) ([]control.OrgPolicy, error)

func (*PolicyService) ListPlatformPolicies

func (s *PolicyService) ListPlatformPolicies(ctx context.Context) ([]control.PlatformPolicy, error)

func (*PolicyService) PreEnableCheck

func (s *PolicyService) PreEnableCheck(ctx context.Context, orgID *uuid.UUID, kind control.PolicyKind) (int64, error)

func (*PolicyService) SetOrgPolicy

func (s *PolicyService) SetOrgPolicy(ctx context.Context, createdBy uuid.UUID, orgID uuid.UUID, kind control.PolicyKind, enabled bool, config control.PolicyConfig) (*control.OrgPolicy, error)

func (*PolicyService) SetPlatformPolicy

func (s *PolicyService) SetPlatformPolicy(ctx context.Context, createdBy uuid.UUID, kind control.PolicyKind, enabled bool, config control.PolicyConfig) (*control.PlatformPolicy, error)

func (*PolicyService) ValidatePassword

func (s *PolicyService) ValidatePassword(ctx context.Context, orgID uuid.UUID, password string) error

type PolicyViolation

type PolicyViolation struct {
	Kind    control.PolicyKind `json:"kind"`
	Message string             `json:"message"`
}

type ProfileResult

type ProfileResult struct {
	User         *control.User
	Organization *control.Organization
	MFAEnabled   bool
	TOTPEnabled  bool
}

type ProfileService

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

func NewProfileService

func NewProfileService(
	users repo.UserRepo,
	organizations repo.OrganizationRepo,
	memberships repo.MembershipRepo,
	totps repo.UserTOTPRepo,
	policies *PolicyService,
	sessions *TokenSessionService,
	audit repo.AuthAuditRepo,
) *ProfileService

func (*ProfileService) ChangePassword

func (s *ProfileService) ChangePassword(ctx context.Context, userID uuid.UUID, in ChangePasswordInput) error

func (*ProfileService) ConfirmEmailChange

func (s *ProfileService) ConfirmEmailChange(ctx context.Context, rawToken string) error

func (*ProfileService) GetProfile

func (s *ProfileService) GetProfile(ctx context.Context, userID, orgID uuid.UUID) (*ProfileResult, error)

func (*ProfileService) RequestEmailChange

func (s *ProfileService) RequestEmailChange(ctx context.Context, userID uuid.UUID, newEmail string) error

func (*ProfileService) SetEmailChange

func (s *ProfileService) SetEmailChange(r repo.EmailChangeRepo, email EmailSender, baseURL string)

func (*ProfileService) UpdateProfile

func (s *ProfileService) UpdateProfile(ctx context.Context, userID uuid.UUID, in UpdateProfileInput) (*control.User, error)

type ProxmoxInventoryService

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

ProxmoxInventoryService reads a hive's live inventory from its Proxmox API.

The ONLY place in this system that knows Proxmox's wire format. Everything downstream — the capacity fold, the drain state, the departure and arrival gates — works on tenant.ResourceRow, so adding a second provider is a second mapper here rather than a change to any of the logic that decides things.

func (*ProxmoxInventoryService) AgentProxmoxCredential

func (s *ProxmoxInventoryService) AgentProxmoxCredential(
	ctx context.Context, orgID, hypervisorID uuid.UUID,
) (tokenID, secret, apiURL string, err error)

AgentProxmoxCredential returns the API token the agent on one machine should hold.

THE HIVE'S CREDENTIAL, and that is forced rather than chosen. Proxmox keeps API tokens in /etc/pve/priv/token.cfg, which lives on pmxcfs and is therefore replicated across the whole cluster: a token minted for one node exists on every node, a cluster join REPLACES that database and destroys whatever the joining node had, and a departure carries the cluster's copy away with it. A per-host credential cannot survive the lifecycle this system automates.

A machine in NO hive returns an empty credential and no error. That is a real state — every migrating host passes through it — and there is no cluster whose token would be the right one. The agent then refuses cluster tasks and says why, which is correct: there is no cluster to act on.

func (*ProxmoxInventoryService) AgentProxmoxCredentialForHive

func (s *ProxmoxInventoryService) AgentProxmoxCredentialForHive(
	ctx context.Context, orgID, hiveID uuid.UUID,
) (tokenID, secret, apiURL string, err error)

AgentProxmoxCredentialForHive returns the token an agent in one fleet should hold.

Named by FLEET rather than by machine because arrival needs it before the machine belongs to that fleet. A join replaces the node's token database with the target cluster's — see the note on pmxcfs above — so the credential the agent must end up with is the one for the cluster it is JOINING, at a moment when its own row still says it is fleetless.

func (*ProxmoxInventoryService) ClusterIdentity

func (s *ProxmoxInventoryService) ClusterIdentity(
	ctx context.Context, orgID, hiveID uuid.UUID,
) (name string, clustered bool, err error)

NodeAddresses returns each cluster member's name mapped to the address the CLUSTER knows it by.

A second call, because /cluster/resources — which Inventory reads — describes capacity and does not carry node addresses. /cluster/status does, and it is the only place this system can learn how to reach a node it did not onboard.

That gap had a visible cost. The capacity sync creates a Hypervisor row for any member nobody onboarded here, with the cluster's name for it and no address, because it had none to record. Onboarding then looks a host up by address, misses that row entirely, and creates a SECOND one for the same physical machine — observed on a live four-node cluster, where pve4 and pve5 each ended up with a name-only row from the sync and an address-only row from a later declaration.

Best-effort by contract: a hive whose API is unreachable returns an error the caller is expected to tolerate. An address is an improvement to a record, not a precondition for observing capacity, and failing a whole sync because one optional field could not be filled would trade a cosmetic gap for a real one. ClusterIdentity reports the cluster name behind a hive's API URL, and whether there is a cluster there at all.

A STANDALONE Proxmox host answers /cluster/status with a single entry of type "node" and no entry of type "cluster" — so it still lists a node, still answers /cluster/resources, and to everything downstream looks like a one-node cluster. It is not one. It has no corosync configuration, nothing can join it, and `pvecm add` pointed at it fails.

That distinction was not being made, and the consequence was a closed loop: the sync recorded the standalone host as the hive's only member, which made bootstrap refuse (the host is in a hive, and the hive is not empty) and made arrival permitted (the target has a node) right up to the point where `pvecm add` was pointed at a machine that is not a cluster. Neither direction could work and nothing said why.

Reads the name off the "cluster" entry, which is the only place it appears. Empty name after a SUCCESSFUL read means standalone; an unreachable endpoint is an error and must never be mistaken for one.

func (*ProxmoxInventoryService) Inventory

func (s *ProxmoxInventoryService) Inventory(ctx context.Context, orgID, hiveID uuid.UUID) ([]tenant.ResourceRow, error)

Inventory returns the hive's flat resource list, mapped into the neutral shape.

func (*ProxmoxInventoryService) NodeAddresses

func (s *ProxmoxInventoryService) NodeAddresses(
	ctx context.Context, orgID, hiveID uuid.UUID,
) (map[string]string, error)

type QueuedEmailSender

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

QueuedEmailSender seals the message body and puts it on the job queue.

Sealing is not optional decoration. The body carries a live token — a confirmation link, a password reset, an invitation — and River stores job args as JSONB in the control database and keeps them after the job completes. In clear, that is a working credential sitting beside the table that deliberately stores only its SHA-256; anyone who could read the jobs table could redeem every pending link. Sealed, that table holds ciphertext.

func NewQueuedEmailSender

func NewQueuedEmailSender(jobs workflow.Enqueuer, masterKey []byte, fallback EmailSender) (*QueuedEmailSender, error)

NewQueuedEmailSender derives its own key from the master key, under a purpose used for nothing else — so a queue payload cannot be opened with a tenant key and vice versa.

fallback may be nil. When set it is used only if enqueueing fails, which keeps a broken queue from silently swallowing a registration confirmation.

func (*QueuedEmailSender) Send

func (q *QueuedEmailSender) Send(ctx context.Context, to, subject, htmlBody string) error

type RefreshInput

type RefreshInput struct {
	RefreshToken string
	IPAddress    string
	UserAgent    string
}

type RefreshResult

type RefreshResult struct {
	Tokens         *IssuedTokens
	OrganizationID string
	Organization   string
}

type RegisterEstateInput

type RegisterEstateInput struct {
	OrgID uuid.UUID

	Name     string
	Provider string
	Scope    map[string]string

	CredentialName string
	CredentialKind string

	// Identity is the NON-SECRET identifying component — an access key id, a
	// token id, a role ARN. It is what gets fingerprinted.
	Identity string
	// Secret is sealed and never fingerprinted or logged.
	Secret repo.Secret
}

type RegisterInput

type RegisterInput struct {
	Email            string
	Password         string
	FirstName        string
	LastName         string
	OrganizationName string
	IPAddress        string
	UserAgent        string
}

type RegisterResult

type RegisterResult struct {
	User           *control.User
	OrganizationID string
	Organization   string
	// VerificationSent reports whether the confirmation email was handed to the
	// mailer. The response says the same thing to the caller either way.
	VerificationSent bool
}

RegisterResult carries no session and no tokens, by design.

Registration used to sign the caller straight in. That made the first token_session for the new organization free — and token_sessions on distinct days are exactly what the proving sweep counts toward retiring the admission ceiling. An attacker got day one of proving for an address they had never read a single message at. Requiring a confirmed address before the first session is what makes the ceiling's premise true.

type RegistrationService

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

func NewRegistrationService

func NewRegistrationService(
	db *gorm.DB,
	users repo.UserRepo,
	policies *PolicyService,
	accessRules AccessRuleResolver,
	admission *AdmissionService,
	verification *EmailVerificationService,
) *RegistrationService

func (*RegistrationService) Register

type RemoveMemberInput

type RemoveMemberInput struct {
	OrgID        uuid.UUID
	MembershipID uuid.UUID
	ActorID      uuid.UUID
	ActorRole    control.MemberRole
}

type ReportInput

type ReportInput struct {
	Session *BeeSession
	TaskID  uuid.UUID
	Status  string
	Phase   string
	Result  []byte
	Error   string
}

type RuleInput

type RuleInput struct {
	Name     string
	Enabled  bool
	Family   string
	Table    string
	Chain    string
	Position int

	SrcCidr string
	DstCidr string

	// SrcSetID and DstSetID point the rule at a named list instead of a literal.
	//
	// The whole reason sets exist: four management addresses across two ports is
	// two rules and one set, where per-rule literals make it eight — and every
	// address added to the whitelist adds another two. Validate refuses a rule
	// that names both a set and a literal, because nft renders one and ignores
	// the other, so the stored rule and the enforced rule would differ with
	// nothing reporting it.
	SrcSetID *uuid.UUID
	DstSetID *uuid.UUID

	Protocol   string
	SrcPort    int
	SrcPortEnd int
	DstPort    int
	DstPortEnd int

	InIface  string
	OutIface string

	Action  string
	Comment string
}

RuleInput is the whole rule. There is no partial update: applying half an edit — a new port against the old action — produces a rule nobody intended.

type SSHKeyService

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

SSHKeyService owns the credential the control plane uses to reach hypervisors.

The private half never leaves this process. It is sealed with a key derived per-tenant, and the only method that unseals it is PrivateKey, which exists for River workers — trusted by design, running with the same environment as the API — and for nothing else. No handler returns it, and no response schema has a field it could occupy.

func NewSSHKeyService

func NewSSHKeyService(tenants TenantResolver) *SSHKeyService

func (*SSHKeyService) ActiveFleetKey

func (s *SSHKeyService) ActiveFleetKey(ctx context.Context, orgID uuid.UUID) (*tenant.SshKey, error)

ActiveFleetKey returns the key an operator should whitelist, and the one a worker should authenticate with.

It refuses rather than picking arbitrarily when several are usable. Two live fleet keys is not a state to paper over: the operator has whitelisted one of them on some hosts and the other elsewhere, and choosing silently means half the fleet becomes unreachable in a way that looks like a network fault.

func (*SSHKeyService) List

func (s *SSHKeyService) List(ctx context.Context, orgID uuid.UUID) ([]tenant.SshKey, error)

List returns the organization's keys. Public halves and fingerprints only — the model's private fields carry json:"-" so no caller can serialise one by accident.

func (*SSHKeyService) MintFleetKey

func (s *SSHKeyService) MintFleetKey(ctx context.Context, orgID uuid.UUID, name string) (*tenant.SshKey, error)

MintFleetKey creates the standing credential for reaching hypervisors.

Unscoped and non-expiring, which is safe only because its private half stays here — see tenant.SSHKeyPurposeFleet. A join key, whose private half must be written to a hypervisor, is minted by MintJoinKey and is neither.

func (*SSHKeyService) MintJoinKey

func (s *SSHKeyService) MintJoinKey(
	ctx context.Context, orgID uuid.UUID, hostID, taskID uuid.UUID, ttl time.Duration,
) (*tenant.SshKey, error)

MintJoinKey creates a single-use credential for one cluster join.

It exists because pvecm's SSH join path (`pvecm add --use_ssh`) still has to authenticate its FIRST step — ssh-copy-id — and normally does so with the target's root password, which nothing in this system owns and which TOTP on root@pam blocks outright. A scoped, expiring key replaces that.

Genuinely single-use: after ssh-copy-id, PVE's own /root/.ssh/id_rsa is on the target and every later step of the join authenticates with it. This key bootstraps one step and is dead weight afterwards, which is why a short TTL is not a compromise.

func (*SSHKeyService) PrivateKey

func (s *SSHKeyService) PrivateKey(ctx context.Context, orgID, id uuid.UUID) ([]byte, error)

PrivateKey unseals a key's private half.

This is the ONLY way to obtain it, and it exists for River workers — trusted by design, running with the same environment as the API. No handler calls it and no response type has a field it could occupy, because a fleet-wide credential passing through an HTTP response would also pass through whatever logs, caches and error trackers sit between here and a browser.

func (*SSHKeyService) Revoke

func (s *SSHKeyService) Revoke(ctx context.Context, orgID, id uuid.UUID) error

Revoke marks a key unusable.

The row is kept rather than deleted so that "which key was this host whitelisted with" stays answerable after the fact — a fingerprint found in an authorized_keys file has to be attributable to something.

type SessionService

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

func NewSessionService

func NewSessionService(
	users repo.UserRepoGorm,
	memberships repo.MembershipRepo,
	organizations repo.OrganizationRepo,
	tokens *TokenService,
	sessions *TokenSessionService,
	policies *PolicyService,
) *SessionService

func (*SessionService) ListMyOrganizations

func (s *SessionService) ListMyOrganizations(
	ctx context.Context,
	userID string,
	currentOrgID string,
) ([]MyOrganizationResult, error)

func (*SessionService) Refresh

func (*SessionService) SwitchOrganization

type SignInAlternatives

type SignInAlternatives interface {
	// Available reports whether an alternative exists, and names it. The name is
	// for the refusal message: "configure a provider" and "you have a passkey"
	// lead an operator to completely different next steps.
	Available(ctx context.Context, actingUserID uuid.UUID) (bool, string, error)
}

SignInAlternatives answers whether any way into the installation survives besides the password form.

An INTERFACE so the flag service does not acquire a dependency on the auth configuration or the passkey store. The direction matters: flags are the general mechanism and sign-in is one thing they happen to gate, so the knowledge flows inward as a small question rather than the flag service learning what a provider is.

type SocialAuthInput

type SocialAuthInput struct {
	Provider string
	Code     string
	IP       string
	UA       string
}

type SocialAuthResult

type SocialAuthResult struct {
	User           *control.User
	OrganizationID string
	Organization   string
	Tokens         *IssuedTokens
	MFARequired    bool
	MFAToken       string

	Mode   string
	Origin string
}

type SocialAuthService

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

func NewSocialAuthService

func NewSocialAuthService(
	cfg *config.Config,
	socialRepo repo.SocialAuthRepo,
	accessRules repo.AuthAccessRuleRepo,
	memberships repo.MembershipRepo,
	totps repo.UserTOTPRepo,
	tokens *TokenService,
	bootstrap *OrgBootstrapService,
	sessions *TokenSessionService,
	policies *PolicyService,
	states repo.OAuthStateRepo,
) *SocialAuthService

func (*SocialAuthService) Callback

func (s *SocialAuthService) Callback(ctx context.Context, providerName, code, state, stateCookie, ip, ua string) (*SocialAuthResult, error)

func (*SocialAuthService) StartURL

func (s *SocialAuthService) StartURL(ctx context.Context, providerName, mode, origin string) (authURL, cookieValue string, err error)

type SocialProviderConfig

type SocialProviderConfig struct {
	Name         string
	ClientID     string
	ClientSecret string
}

SocialProviderConfig is one provider as configured.

func (SocialProviderConfig) Configured

func (p SocialProviderConfig) Configured() bool

Configured requires BOTH halves. A client id with no secret is a provider somebody started wiring up and did not finish; treating it as a working way in is how the guard would wave through the exact lockout it exists to prevent.

type SourceClassifier

type SourceClassifier interface {
	Classify(in SourceInput) SourceKey
}

SourceClassifier turns a client address into a ceiling bucket and a trust tier. This interface is the seam; the shipped implementation deliberately covers only what can be decided offline from operator-supplied ranges.

A production classifier wants real network intelligence. Accurate options, as far as I know them — this is not an exhaustive list and the commercial landscape moves:

  • MaxMind GeoLite2-ASN: free, offline MMDB, gives ASN and AS organisation. The practical basis for datacenter-vs-ISP and for the corroboration check.
  • Team Cymru IP-to-ASN: free DNS/whois service. Authoritative, but it is a network call.
  • RDAP (the structured successor to WHOIS): gives the organisation a block is allocated to. bgp.he.net presents the same underlying BGP and RIR data but as a web interface rather than a documented API, so RDAP or RIPEstat is the programmatic route.
  • The Tor Project's bulk exit list: free and published, for SourceAnonymised.
  • Cloud providers' own published ranges (AWS ip-ranges.json, Google cloud.json, Azure service tags, and equivalents): free, authoritative, offline — the best starting point for SourceDatacenter.
  • Commercial feeds (IPinfo, Spur, IPQualityScore and others) additionally flag VPNs, residential proxies and hosting.

The strongest signal is not any single lookup but CORROBORATION: an address inside the allocation of the same organisation that owns the email's registrable domain means two independent attributes agree, and an attacker would have to control both. That is the property the domain-only key was reaching for and never had. It is why Classify takes the email domain.

Do NOT put a network call on this path. An admission decision that depends on a third party's availability fails whenever they do, and this gate sits in front of registration.

type SourceInput

type SourceInput struct {
	ClientIP string

	// ForwardedFor is the raw inbound header, present only so the interlock can
	// detect a proxy this server is not configured to parse.
	ForwardedFor string

	// TrustedProxies reports whether TRUSTED_PROXY_CIDRS is configured, i.e.
	// whether ClientIP was derived from a header this server actually trusts.
	TrustedProxies bool

	// RegistrableDomain is the email's eTLD+1, carried so a classifier can make
	// the corroboration check described above. The shipped implementation does
	// not use it; a classifier with ASN data should.
	RegistrableDomain string
}

SourceInput is what the classifier reasons about.

type SourceKey

type SourceKey struct {
	// Key is the bucket. Empty means the source could not be established, in
	// which case Trusted is false and the caller must not enforce on it.
	Key string

	// Trusted is false when the client address cannot be distinguished at all —
	// see the interlock in Classify. The ceiling degrades to observe-only
	// rather than enforce a key it knows is wrong.
	Trusted bool

	// Coarse records bucketing at network rather than address granularity, so a
	// denial can be explained honestly: "one bucket for this whole network" is
	// a very different message from "one bucket for your address".
	Coarse bool

	Trust SourceTrust
}

SourceKey identifies the network origin of an admission request, forming the second half of the unproven-organization ceiling's key.

Why the ceiling needs a second half at all: keyed on the registrable domain ALONE it is a denial-of-service primitive rather than a control. An attacker registers three times as a@victim-corp.com, b@ and c@ — nothing on that path checks mailbox ownership — and every real employee of that company is refused permanently, because the ceiling has no time component and nothing clears it. The general rule: a quota keyed on an attribute the attacker can assert about SOMEONE ELSE is a weapon, not a bound. Adding the source makes the budget attacker-local, so they can only exhaust their own slice.

type SourceTrust

type SourceTrust int

SourceTrust ranks how much an admission decision may lean on a client address. It is a tier rather than a boolean because the addresses behave genuinely differently, and the ceiling should scale with the tier rather than treating "came from somewhere" as one fact.

Ordered least to most trusted, so a caller may compare.

const (
	// SourceAnonymised is a Tor exit, open proxy or commercial anonymiser.
	// Addresses here are shared, disposable and deliberately unattributable, so
	// they carry no information about who is asking. The Tor Project publishes
	// its exit list; commercial feeds cover the rest.
	SourceAnonymised SourceTrust = iota

	// SourceDatacenter is cloud or hosting. Automation lives here, and — the
	// point that matters for the ceiling — addresses inside a provider's range
	// are free to an attacker. Bucketing these per address hands out unlimited
	// buckets and the ceiling stops bounding anything, so they are bucketed at
	// network granularity instead.
	SourceDatacenter

	// SourceEnterpriseEgress is a corporate VPN concentrator or a SASE/ZTNA
	// vendor's egress range — Zscaler, Netskope, Cloudflare WARP, Prisma and
	// the like. By ASN these are indistinguishable from cloud hosting, which is
	// why a naive tier model penalises them hardest: the organisations most
	// likely to mandate full-tunnel egress are the regulated ones whose staff
	// are the LEAST likely to be abusing anything. Treating a bank's entire
	// workforce as untrusted cloud traffic is precisely backwards.
	//
	// The vendors publish their egress ranges, so this is the same mechanism as
	// the cloud list with the opposite treatment.
	SourceEnterpriseEgress

	// SourceResidential is an ISP pool: consumer broadband, mobile carriers,
	// dynamic DNS. Where real people are. Note the pools are dynamic, so a
	// per-address bucket leaks in both directions over time — a legitimate
	// user's address changes, and an attacker can re-dial for a fresh one.
	SourceResidential

	// SourceCorporate is an address in a block registered to an organisation
	// rather than to an access provider — someone on their employer's network —
	// where that organisation does NOT correspond to the email's domain.
	SourceCorporate

	// SourceCorroborated is the same, except the block's registered owner DOES
	// correspond to the email's registrable domain. Two independent attributes
	// agree, and an attacker would have to control both: the mailbox at the
	// domain, and an address inside that organisation's own allocation. This is
	// the strongest signal available without verifying the mailbox itself.
	SourceCorroborated
)

type StartImpersonationInput

type StartImpersonationInput struct {
	AdminID        uuid.UUID
	TargetUserID   uuid.UUID
	OrganizationID uuid.UUID
	Reason         string
	IPAddress      string
	UserAgent      string
}

type StartImpersonationResult

type StartImpersonationResult struct {
	AccessToken    string
	TokenType      string
	ExpiresIn      int
	OrganizationID string
	TargetUser     *control.User
}

type SwitchOrganizationInput

type SwitchOrganizationInput struct {
	UserID         string
	OrganizationID string
	IPAddress      string
	UserAgent      string

	MFA bool
}

type SwitchOrganizationResult

type SwitchOrganizationResult struct {
	Tokens         *IssuedTokens
	OrganizationID string
	Organization   string
}

type TOTPSetupResult

type TOTPSetupResult struct {
	Secret  string
	URI     string
	QRImage string
}

type TenantResolver

type TenantResolver interface {
	GetTenantContext(ctx context.Context, orgID string) (*database.TenantContext, error)
}

TenantResolver is the one thing this service needs from the tenant manager: given an organization, hand back that organization's database handle and its unwrapped tenant key.

It is declared here, on the CONSUMER side, deliberately. Depending on the concrete *database.TenantManager meant the service could not be reached without a provisioned tenant — a control-plane organization row carrying an encrypted DSN and a wrapped key — so the layer that keeps estate and credential consistent had no test at all, and the tests it could have had would have re-tested the tenant manager instead. *database.TenantManager satisfies this verbatim, so no caller changes.

type ToggleSupportInput

type ToggleSupportInput struct {
	UserID         uuid.UUID
	OrganizationID uuid.UUID
	Enabled        bool
	IPAddress      string
	UserAgent      string
}

type TokenPair

type TokenPair struct {
	AccessToken  string
	RefreshToken string
	TokenType    string
	ExpiresIn    int
	// RefreshExpiresIn is the refresh token's lifetime in seconds.
	//
	// Sent because a client that knows only ExpiresIn cannot tell a session that
	// needs refreshing from one that is over. The UI was discarding a stored
	// session the moment the ACCESS token expired — fifteen minutes — while the
	// refresh token it was holding stayed valid for another seven days, so
	// closing a tab over lunch meant signing in again for no reason.
	RefreshExpiresIn int
}

type TokenService

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

func NewTokenService

func NewTokenService(secret, issuer string, accessTTL, refreshTTL time.Duration) (*TokenService, error)

func (*TokenService) Issue

func (s *TokenService) Issue(userID, orgID string, now time.Time, opts ...IssueOpts) (*IssuedTokens, error)

func (*TokenService) IssueMFAPending

func (s *TokenService) IssueMFAPending(userID, orgID string, now time.Time) (string, error)

func (*TokenService) IssueMFAPendingWithJTI

func (s *TokenService) IssueMFAPendingWithJTI(userID, orgID string, now time.Time) (string, uuid.UUID, error)

func (*TokenService) Parse

func (s *TokenService) Parse(tokenStr, expectedType string) (*AuthClaims, error)

type TokenSessionService

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

func NewTokenSessionService

func NewTokenSessionService(
	tokens *TokenService,
	sessions repo.TokenSessionRepo,
	audit repo.AuthAuditRepo,
	users repo.UserRepo,
) *TokenSessionService

func (*TokenSessionService) DeleteExpired

func (s *TokenSessionService) DeleteExpired(ctx context.Context) (int64, error)

func (*TokenSessionService) IssueTracked

func (*TokenSessionService) ListActive

func (s *TokenSessionService) ListActive(ctx context.Context, subjectID uuid.UUID, subjectKind string) ([]control.TokenSession, error)

func (*TokenSessionService) Refresh

func (s *TokenSessionService) Refresh(ctx context.Context, refreshTokenRaw, ip, ua string, overrideOpts ...IssueOpts) (*IssuedTokens, string, error)

func (*TokenSessionService) Revoke

func (s *TokenSessionService) Revoke(ctx context.Context, accessJTI, subjectID, ip, ua string) error

func (*TokenSessionService) RevokeAll

func (s *TokenSessionService) RevokeAll(ctx context.Context, subjectID uuid.UUID, subjectKind, ip, ua string) error

func (*TokenSessionService) SetAccessRules

func (s *TokenSessionService) SetAccessRules(r repo.AuthAccessRuleRepo)

func (*TokenSessionService) Tokens

func (s *TokenSessionService) Tokens() *TokenService

type UpdateProfileInput

type UpdateProfileInput struct {
	FirstName *string
	LastName  *string
	Email     *string
}

type UpdateRoleInput

type UpdateRoleInput struct {
	OrgID        uuid.UUID
	MembershipID uuid.UUID
	NewRole      control.MemberRole
	ActorID      uuid.UUID
	ActorRole    control.MemberRole
}

Jump to

Keyboard shortcuts

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