service

package
v1.1.0 Latest Latest
Warning

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

Go to latest
Published: Jun 15, 2026 License: AGPL-3.0 Imports: 29 Imported by: 0

Documentation

Overview

Package service implements the business logic for the identity service.

The AuthService sits between the Connect-Go handler layer and the EntDB persistence layer. It contains all authentication, token management, and 2FA logic. It does NOT import Connect/protobuf types -- it uses plain Go structs and returns errors that the handler translates to gRPC codes.

Security invariants:

  • Passwords, secrets, and tokens are NEVER logged.
  • Failed login lockout: 5 attempts, 15-min lock (configurable).
  • TOTP challenges: 5-min expiry, single-use.
  • QR login sessions: 5-min expiry, consumed after token issuance.
  • Refresh tokens: rotated on every use (old token deleted, new one issued).
  • All security events are audit-logged via the audit.Logger.

Package service — stub implementations for Repository and DB.

These stubs return ErrServiceUnavailable for every operation. They exist so the identity service binary can start and serve health checks / JWKS even when the EntDB persistence adapter has not yet been wired up. Any RPC that touches persistence will receive a clean "service unavailable" error instead of a nil-pointer panic.

Index

Constants

View Source
const (
	CredentialKindPublishable = "publishable"
	CredentialKindSecret      = "secret"
)

Credential kinds an operator may mint. "publishable" is a public lookup key with no secret half; "secret" carries a secret shown exactly once.

View Source
const (
	IDVStatusPending  = "pending"
	IDVStatusInReview = "in_review"
	IDVStatusApproved = "approved"
	IDVStatusRejected = "rejected"
	IDVStatusExpired  = "expired"
)

Identity-verification status string constants. Mirrored as proto enum values in IdentityVerificationStatus.

View Source
const (
	LoginMethodEmailOTP = "email_otp"
	LoginMethodPassword = "password"
	LoginMethodOAuth    = "oauth"
	LoginMethodPasskey  = "passkey"
	LoginMethodSSO      = "sso"
)

Login method tokens used in LoginPolicy.AllowedMethods (a comma-separated list). Empty AllowedMethods means "no restriction" — the caller falls back to its safe default rather than locking the tenant out.

View Source
const (
	MembershipSourceDomain  = "domain"  // derived from a verified email domain
	MembershipSourceInvited = "invited" // accepted an invitation
	MembershipSourceAdded   = "added"   // added by a tenant admin
)

Membership source — how the row came to exist.

View Source
const (
	RoleMember = "member"
	RoleAdmin  = "admin"
	RoleOwner  = "owner"
)

Membership / invitation role.

View Source
const (
	MembershipStatusActive   = "active"
	MembershipStatusPending  = "pending"
	MembershipStatusInactive = "inactive"
)

Membership status.

View Source
const (
	InvitationStatusPending  = "pending"
	InvitationStatusAccepted = "accepted"
	InvitationStatusRevoked  = "revoked"
	InvitationStatusExpired  = "expired"
)

Invitation status.

View Source
const (
	PlatformAdminStatusActive    = "active"
	PlatformAdminStatusSuspended = "suspended"
)

PlatformAdmin status — an active operator may sign in; a suspended one is retained for audit but cannot.

View Source
const (
	// TenantStatusLatent is a tenant auto-formed from a user's email domain
	// that has not yet had that domain verified. It governs nothing until
	// claimed.
	TenantStatusLatent = "latent"
	// TenantStatusClaimed is a tenant whose domain has been verified; its
	// login policy and membership are now authoritative.
	TenantStatusClaimed = "claimed"
	// TenantStatusSuspended is an administratively disabled tenant.
	TenantStatusSuspended = "suspended"
)

Tenant status values.

View Source
const (
	DomainStatusPending  = "pending"
	DomainStatusVerified = "verified"
	DomainStatusFailed   = "failed"
)

Domain status values.

View Source
const (
	DomainVerificationDNSTXT = "dns_txt"
	DomainVerificationEmail  = "email"
)

Domain verification methods.

Variables

View Source
var (
	ErrUnauthenticated   = errors.New("unauthenticated")
	ErrPermissionDenied  = errors.New("permission denied")
	ErrInvalidArgument   = errors.New("invalid argument")
	ErrNotFound          = errors.New("not found")
	ErrAlreadyExists     = errors.New("already exists")
	ErrAccountLocked     = errors.New("account locked")
	ErrNoPasswordSet     = errors.New("no password set for this account")
	ErrAccountNotActive  = errors.New("account is not active")
	ErrInvitationPending = errors.New("account has not completed invitation")
	ErrIDVRequired       = errors.New("identity verification required")
	ErrWeakPassword      = errors.New("password does not meet strength requirements")
	ErrTotpRequired      = errors.New("totp required")
	// ErrSSORequired is returned when a claimed tenant's LoginPolicy mandates
	// single sign-on and the caller attempted a non-SSO method. Like
	// ErrTotpRequired it is a "do something else first" signal rather than a
	// hard failure, so the Connect handler maps it to CodeFailedPrecondition,
	// steering the client to the tenant's SSO connection.
	ErrSSORequired       = errors.New("sso required for this domain")
	ErrTokenExpired      = errors.New("token expired")
	ErrInvalidTotpCode   = errors.New("invalid totp code")
	ErrQrLoginExpired    = errors.New("qr login session expired")
	ErrQrLoginNotPending = errors.New("qr login session is not pending")
	// ErrOAuthCodeInvalid is returned when a hosted-flow one-time code is
	// missing, expired, or already consumed. The Connect handler maps it
	// to CodeUnauthenticated so replays and expiries look identical to a
	// brute-force attacker.
	ErrOAuthCodeInvalid = errors.New("oauth one-time code is invalid or already used")
	// ErrEmailLoginCodeInvalid is returned when a passwordless OTP is
	// missing, expired, already consumed, the wrong code, or has exhausted
	// its attempt budget. The Connect handler maps it to
	// CodeUnauthenticated so all failure modes look identical to a
	// brute-force attacker.
	ErrEmailLoginCodeInvalid = errors.New("email login code is invalid or expired")
	// ErrMagicLinkInvalid is returned when a passwordless magic-link token
	// is missing, expired, or already consumed. Maps to CodeUnauthenticated.
	ErrMagicLinkInvalid = errors.New("magic link is invalid or already used")
	// ErrPhoneCodeInvalid is returned when an SMS-OTP phone-verification
	// code is missing, expired, already consumed, the wrong code, or has
	// exhausted its attempt budget. The Connect handler maps it to
	// CodeUnauthenticated so all failure modes look identical to a
	// brute-force attacker.
	ErrPhoneCodeInvalid = errors.New("phone verification code is invalid or expired")
	// ErrSMSDisabled is returned by the phone-verification RPCs when
	// GATEWAY_SMS_ENABLED is false. Maps to CodeUnavailable.
	ErrSMSDisabled = errors.New("sms phone verification is not configured")
	// ErrPhoneAlreadyVerified is returned by RequestPhoneVerification when
	// the caller has already verified the same number. Maps to
	// CodeAlreadyExists.
	ErrPhoneAlreadyVerified = errors.New("phone number is already verified")
	ErrInvitationUsed       = errors.New("invitation has already been accepted")
	ErrInvitationExpired    = errors.New("invitation has expired")
	ErrLocalAuthDisabled    = errors.New("local auth disabled")
	ErrOAuthDisabled        = errors.New("oauth login is not configured")
	ErrSignupDisabled       = errors.New("signup is disabled for this deployment")
	// ErrCaptchaRequired is returned when CAPTCHA is enforced on an
	// endpoint but the request carried no captcha token. ErrCaptchaFailed
	// is returned when the supplied token was rejected by the provider.
	// Both map to CodePermissionDenied so a forged token and a missing one
	// look the same to a client.
	ErrCaptchaRequired = errors.New("captcha token required")
	ErrCaptchaFailed   = errors.New("captcha verification failed")
	// ErrUnimplemented signals that the requested RPC is intentionally
	// disabled for the active repository driver (e.g. the redesign
	// Domain/Tenant RPCs are postgres-only; entdb/memory return this).
	// The Connect handler layer maps it to CodeUnimplemented.
	ErrUnimplemented = errors.New("operation unimplemented for this repository driver")
	// ErrLastOwner is returned by RemoveTenantMember when removing the target
	// would strand the tenant with no active owner. The caller is permitted
	// to remove members (so PermissionDenied is wrong); this is a state
	// precondition, mapped to CodeFailedPrecondition.
	ErrLastOwner = errors.New("cannot remove the last owner of a tenant")
	// ErrPlatformAdminExists is returned by CreateFirstPlatformAdmin once any
	// platform admin already exists: the zero-config bootstrap is a one-time
	// path that permanently closes after the first admin is created, so a
	// later call cannot escalate to operator. It is a state precondition (not
	// an authorization failure), mapped to CodeFailedPrecondition.
	ErrPlatformAdminExists = errors.New("a platform admin already exists; bootstrap is closed")
)
View Source
var ErrServiceUnavailable = errors.New("identity: persistence layer not configured")

ErrServiceUnavailable is returned by stub implementations.

View Source
var ErrSweepNotImplemented = errors.New("identity: sweep not implemented for this backend")

ErrSweepNotImplemented is the soft-skip sentinel a Repository may return from a DeleteExpired* method when the backend cannot yet run the expired-row sweep. The sweeper goroutine in internal/app/sweeper.go logs this once per node type per process and continues. No backend in tree returns this today; it remains so a new backend can land its CRUD methods first and its sweep in a follow-up PR without erroring the sweeper goroutine.

Functions

func WithProjectScope added in v0.16.0

func WithProjectScope(ctx context.Context, scope *ProjectScope) context.Context

WithProjectScope returns a child context carrying scope. The project-resolution middleware calls this once it has resolved the request's project. A nil scope is a no-op so callers need not branch.

Types

type AdminProject added in v0.19.0

type AdminProject struct {
	ID             string
	StorageScopeID string
	Name           string
}

AdminProject is the control-plane project row an operator creates. It is a driver-agnostic value type so the admin service and its tests depend on a contract, not the concrete postgres store type.

type AdminProjectAuthDomain added in v1.1.0

type AdminProjectAuthDomain struct {
	Hostname     string
	IsPrimary    bool
	VerifiedAtMs int64
}

AdminProjectAuthDomain is a project's serving hostname as the admin service reads it back. VerifiedAtMs is 0 until ownership is proven; a domain with 0 does NOT resolve requests.

type AdminProjectCredential added in v0.19.0

type AdminProjectCredential struct {
	ID         string
	ProjectID  string
	Kind       string
	PublicID   string
	SecretHash string
}

AdminProjectCredential is the credential row an operator mints. Only the hash of the secret is persisted; the raw secret never round-trips through the store.

type AdminService

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

AdminService implements admin user-management operations. All methods verify that the acting user has role=admin.

func NewAdminService

func NewAdminService(repo Repository, db DB, projectID string, auditLog *audit.Logger, cfg *config.Config, mailer email.Transport, logger *zap.Logger) *AdminService

NewAdminService creates an AdminService.

The repo handle backs the cascade-aware paths (DeleteUser, and the session/refresh-token revocation done by DeactivateUser); the DB handle backs the admin-authorization read and the existing invite/deactivate/reactivate/reset graph writes. This mirrors the dual-handle ProfileService(repo, db, …) pattern.

mailer may be nil; if nil, a log-only transport is substituted so invitation emails are at least visible in the logs during local dev. Email-side-effect failures never block the surrounding RPC.

func (*AdminService) DeactivateUser

func (s *AdminService) DeactivateUser(ctx context.Context, actorID, targetUserID, reason string) error

DeactivateUser marks a user as deactivated, then revokes their active sessions and deletes their refresh tokens so the suspension takes effect immediately rather than at the next token's natural expiry. The user row is retained (reversible via ReactivateUser).

func (*AdminService) DeleteUser added in v0.13.0

func (s *AdminService) DeleteUser(ctx context.Context, actorID, targetUserID string) error

DeleteUser physically removes a user and cascades all user-owned records (sessions, refresh/login challenges, passkeys, totp, recovery codes, oauth identities, qr sessions, one-time codes, idv records, invitations, and the password/email-verification/ email-change tokens), plus the user's group MEMBER_OF edges. After it, GetUser returns NotFound and the email is reusable. Audit events are retained for accountability.

Active sessions and refresh tokens are revoked BEFORE the cascade so any in-flight access token is dead immediately rather than at its natural expiry.

func (*AdminService) GetUser

func (s *AdminService) GetUser(ctx context.Context, actorID, userID string) (*User, error)

GetUser returns a single user by ID.

func (*AdminService) InviteUser

func (s *AdminService) InviteUser(
	ctx context.Context,
	actorID, email, name, role, recoveryEmail string,
	quotaBytes int64, createImmediately bool,
) (*InviteResult, error)

InviteUser creates a new user (invited or immediately active) and returns the user, an invitation token, setup URL, and optional temporary password.

func (*AdminService) ListUsers

func (s *AdminService) ListUsers(
	ctx context.Context, actorID, statusFilter, search, cursor string, limit int,
) ([]*User, string, int, error)

ListUsers returns a paginated list of users, optionally filtered by status and/or search substring.

func (*AdminService) ReactivateUser

func (s *AdminService) ReactivateUser(ctx context.Context, actorID, targetUserID string) error

ReactivateUser sets a deactivated user back to active.

func (*AdminService) ResetUserPassword

func (s *AdminService) ResetUserPassword(
	ctx context.Context, actorID, targetUserID string, generateTemp bool,
) (*ResetPasswordResult, error)

ResetUserPassword generates a temp password or reset token for a user.

func (*AdminService) SetUserQuota

func (s *AdminService) SetUserQuota(ctx context.Context, actorID, targetUserID string, quotaBytes int64) error

SetUserQuota updates the storage quota for a user.

func (*AdminService) UpdateUser

func (s *AdminService) UpdateUser(ctx context.Context, actorID, userID, name, role, avatarURL string) (*User, error)

UpdateUser patches name, role, and/or avatar_url for a user.

type AuditEvent

type AuditEvent struct {
	ID           string
	EventType    string
	ActorUserID  string
	TargetUserID string
	IPAddress    string
	UserAgent    string
	Success      bool
	Details      map[string]any
	CreatedAt    int64
}

AuditEvent is the domain representation of an audit event (type_id 26).

type AuthService

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

AuthService implements authentication and token management business logic.

func NewAuthService

func NewAuthService(
	repo Repository,
	cfg *config.Config,
	signer jwt.Signer,
	passkeysSvc *passkeys.WebAuthnService,
	auditLogger *audit.Logger,
	totpKey []byte,
	totpRecoveryPepper []byte,
	mailer email.Transport,
	smsSender sms.Sender,
	logger *zap.Logger,
) *AuthService

NewAuthService creates an AuthService with all required dependencies.

mailer may be nil; if nil, a non-delivering log-only transport is substituted so service code can always call s.mailer.Send without a nil check. Email-side-effect failures are logged and do NOT fail the surrounding RPC.

func NewAuthServiceWithOAuth

func NewAuthServiceWithOAuth(
	repo Repository,
	cfg *config.Config,
	signer jwt.Signer,
	passkeysSvc *passkeys.WebAuthnService,
	auditLogger *audit.Logger,
	totpKey []byte,
	totpRecoveryPepper []byte,
	mailer email.Transport,
	smsSender sms.Sender,
	logger *zap.Logger,
	oauthRegistry *oauth.Registry,
) *AuthService

NewAuthServiceWithOAuth is the extended constructor that injects an oauth.Registry. Pass nil to disable OAuth login.

func (*AuthService) AcceptInvitation

func (s *AuthService) AcceptInvitation(ctx context.Context, invitationToken, password, name, ipAddr, userAgent string) (*LoginResult, error)

AcceptInvitation completes an admin-issued invitation.

func (*AuthService) ApproveQrLogin

func (s *AuthService) ApproveQrLogin(ctx context.Context, sessionID string, approve bool, userID, userAgent string) (string, error)

ApproveQrLogin approves or rejects a QR login session. Returns the new status.

func (*AuthService) BeginHostedOAuth added in v0.9.0

func (s *AuthService) BeginHostedOAuth(
	ctx context.Context,
	provider, redirectURI, returnTo string,
) (*HostedOAuthBeginResult, error)

BeginHostedOAuth mints state + PKCE for the hosted flow and returns the provider authorization URL. redirectURI is the identity-owned callback (e.g. https://identity.example.com/oauth/callback/google); returnTo is the already-allowlist-validated app URL the callback will redirect back to. It reuses the same provider Authorizer the headless BeginOAuthLogin uses — there is no forked authorization path.

func (*AuthService) BeginOAuthLogin

func (s *AuthService) BeginOAuthLogin(
	ctx context.Context,
	provider, redirectURI string,
) (*OAuthBeginResult, error)

BeginOAuthLogin returns a provider authorization URL plus the server-minted state artifacts needed to complete the callback safely.

func (*AuthService) BeginPasskeyLogin

func (s *AuthService) BeginPasskeyLogin(ctx context.Context, email string) (string, string, error)

BeginPasskeyLogin generates WebAuthn authentication options. If email is provided, scopes to that user's credentials. Otherwise allows discoverable credentials (usernameless flow).

func (*AuthService) BeginPasskeyRegistration

func (s *AuthService) BeginPasskeyRegistration(ctx context.Context, userID, deviceName string) (string, string, error)

BeginPasskeyRegistration generates WebAuthn registration options for the authenticated user. Returns (optionsJSON, challengeID, error).

func (*AuthService) BeginTotpSetup

func (s *AuthService) BeginTotpSetup(ctx context.Context, userID string) (string, string, []string, error)

BeginTotpSetup starts TOTP enrollment for the authenticated user. Returns (secret, qrURI, recoveryCodes, error).

func (*AuthService) CompleteHostedOAuth added in v0.9.0

func (s *AuthService) CompleteHostedOAuth(
	ctx context.Context,
	providerFromPath, code, stateToken, ipAddr, userAgent string,
) (*HostedOAuthCallbackResult, error)

CompleteHostedOAuth runs the hosted callback: it verifies the signed hosted state token (recovering provider + PKCE verifier + return_to), runs the same OAuthLogin exchange the headless flow uses, then mints a single-use one-time code bound to the authenticated user. The caller (the HTTP handler) 302-redirects to result.ReturnTo?code=result.Code.

stateToken is the OAuth `state` value the provider echoed back; providerFromPath is the provider segment from the callback path, used only to cross-check the token's provider claim.

func (*AuthService) CompletePasskeyLogin

func (s *AuthService) CompletePasskeyLogin(ctx context.Context, challengeID, credentialJSON, ipAddr, userAgent string) (*LoginResult, error)

CompletePasskeyLogin verifies the assertion response and issues tokens.

func (*AuthService) CompletePasskeyRegistration

func (s *AuthService) CompletePasskeyRegistration(ctx context.Context, userID, challengeID, credentialJSON, deviceName string) (*PasskeyInfo, error)

CompletePasskeyRegistration verifies the attestation response and stores the new credential. Returns (credentialInfo, error).

func (*AuthService) ConfirmEmailChange

func (s *AuthService) ConfirmEmailChange(ctx context.Context, token string) (*User, error)

ConfirmEmailChange consumes a pending EmailChangeToken and swaps the user's primary email to the verified new address. Token must be unconsumed and unexpired. On success:

  • user.email is updated to new_email
  • user.email_verified is set to true (verified by clicking the link)
  • user.email_verified_at is updated
  • the token is marked consumed
  • all of the user's refresh tokens are revoked (OAuth 2.1 §4.13: credential changes force re-auth on every device)

If the new address has been claimed by another user since the request, returns ErrAlreadyExists — the token is NOT consumed in that case so the user can call ConfirmEmailChange again if the conflict resolves before the token expires.

func (*AuthService) ConfirmPasswordReset

func (s *AuthService) ConfirmPasswordReset(ctx context.Context, token, newPassword string) error

ConfirmPasswordReset consumes a password-reset token and sets the user's new password.

Token must be unconsumed and unexpired. On success, every refresh token belonging to the user is revoked — OAuth 2.1 §4.13 best practice for any credential change forces re-login on all devices.

func (*AuthService) DisableTotp

func (s *AuthService) DisableTotp(ctx context.Context, userID, password string) error

DisableTotp disables 2FA for the authenticated user. Requires password confirmation.

func (*AuthService) GetCurrentUser

func (s *AuthService) GetCurrentUser(ctx context.Context, userID string) (*User, error)

GetCurrentUser returns the user record for the given user ID.

func (*AuthService) GetQrLoginSession

func (s *AuthService) GetQrLoginSession(ctx context.Context, sessionID string) (*QrSessionInfo, error)

GetQrLoginSession returns display-safe details of a QR login session.

func (*AuthService) InitiateQrLogin

func (s *AuthService) InitiateQrLogin(ctx context.Context, deviceInfo, userAgent, ipAddr string) (*InitiateQrLoginResult, error)

InitiateQrLogin creates a new QR login session for an unauthenticated device. The returned PollSecret is shown only to the initiating device and must be presented on every PollQrLogin call; it is NEVER embedded in the QR URL.

func (*AuthService) Logout

func (s *AuthService) Logout(ctx context.Context, rawRefreshToken string) error

Logout deletes the refresh token identified by the raw token value.

func (*AuthService) OAuthLogin

func (s *AuthService) OAuthLogin(
	ctx context.Context,
	code, provider, redirectURI, codeVerifier, state, stateToken, ipAddr, userAgent string,
) (*LoginResult, error)

OAuthLogin performs the full OAuth code-exchange flow: it looks up the registered Exchanger for the provider, swaps the code for a verified Identity, then upserts the local user and issues tokens.

The frontend / gateway is NOT trusted to validate the user's identity; identity does the exchange itself. Provider access / refresh tokens are discarded — they are not persisted.

func (*AuthService) PasswordLogin

func (s *AuthService) PasswordLogin(ctx context.Context, email, password, ipAddr, userAgent string) (*LoginResult, error)

PasswordLogin authenticates a user with email + password. If TOTP is enabled, returns TotpRequired=true with a LoginChallengeID.

func (*AuthService) PasswordSignup

func (s *AuthService) PasswordSignup(ctx context.Context, email, password, name, recoveryEmail string) (*LoginResult, error)

PasswordSignup creates a new user with email + password and issues tokens.

func (*AuthService) PollQrLogin

func (s *AuthService) PollQrLogin(ctx context.Context, sessionID, pollSecret, ipAddr, userAgent string) (*PollQrResult, error)

PollQrLogin polls a QR login session. When approved, atomically consumes the session and issues tokens. Returns (status, user, accessToken, refreshToken, error). pollSecret must match the value returned by InitiateQrLogin; otherwise the session appears "expired" to the caller — a stolen QR URL alone is useless.

Multi-replica correctness: the approved→consumed transition runs through the repository's ConsumeQrLoginSession compare-and-set primitive BEFORE tokens are minted, so only one of N concurrent pollers against the same approved session can complete the flow. The loser sees status="consumed" on the next poll cycle.

func (s *AuthService) RedeemMagicLink(ctx context.Context, token, ipAddr, userAgent string) (*MagicLinkResult, error)

RedeemMagicLink consumes the single-use token, resolves-or-creates the user keyed by the bound email, and issues a token pair. A replay, an expired token, or an unknown token all return ErrMagicLinkInvalid.

func (*AuthService) RedeemOAuthCode added in v0.9.0

func (s *AuthService) RedeemOAuthCode(ctx context.Context, code, ipAddr, userAgent string) (*LoginResult, error)

RedeemOAuthCode exchanges the single-use hosted-flow code for a fresh token pair. The repository's ConsumeOAuthOneTimeCode is the serialization point: it atomically consumes the code (single winner across replicas) and returns the bound user, after which tokens are minted via the same issueTokens path every other login uses. A replay, an expired code, or an unknown code all return ErrOAuthCodeInvalid.

func (*AuthService) RefreshToken

func (s *AuthService) RefreshToken(ctx context.Context, rawRefreshToken, ipAddr, userAgent string) (*User, string, string, error)

RefreshToken validates a refresh token, rotates it, and returns new tokens.

Replay detection is durable: rotated tokens are kept in the repository with consumed_at != 0 instead of being deleted, so any instance — and any process restart — can detect a stolen-token replay (OAuth 2.1 §4.13). When replay is detected, ALL of the user's refresh tokens are hard-deleted to bound the blast radius.

Concurrency: the repository's ConsumeRefreshTokenByHash is the serialization point. Two goroutines presenting the same refresh token race to consume it; the loser sees ErrUnauthenticated and the row stays consumed exactly once.

Background sweep: rows whose ConsumedAtMs is older than the desired retention window (e.g. 90 days) may be hard-deleted by a periodic job. That sweep is intentionally NOT implemented here.

func (*AuthService) RegenerateRecoveryCodes

func (s *AuthService) RegenerateRecoveryCodes(ctx context.Context, userID, password string) ([]string, error)

RegenerateRecoveryCodes issues a fresh batch of recovery codes. Requires password confirmation and TOTP to be enabled.

func (*AuthService) RequestEmailChange

func (s *AuthService) RequestEmailChange(ctx context.Context, userID, newEmail, currentPassword string) error

RequestEmailChange initiates a primary-email rotation for the user.

The caller must re-authenticate with their current password (OAuth 2.1 best practice for any high-value credential change). The new address is validated and checked for uniqueness; an EmailChangeToken is created and two emails are dispatched:

  • to the NEW address: a verification link the user must click to complete the change.
  • to the OLD address: a security notice informing the user that a change has been requested.

The email swap does NOT take effect until ConfirmEmailChange consumes the token. If another user claims the new_email between this call and the confirm call, the confirm call will fail with ErrAlreadyExists (last-write-wins is rejected — the contended new_email is still owned by the other user, so we cannot reassign it).

func (*AuthService) RequestEmailLoginCode added in v0.11.0

func (s *AuthService) RequestEmailLoginCode(ctx context.Context, emailAddr string) error

RequestEmailLoginCode mints a 6-digit OTP for the email and dispatches it. Anti-enumeration: it always returns nil with no observable difference between a known and an unknown address. The account is NOT created here — only VerifyEmailLoginCode resolves or creates the user.

func (s *AuthService) RequestMagicLink(ctx context.Context, emailAddr, returnTo string) error

RequestMagicLink mints a single-use magic-link token bound to the email and the allowlist-validated return_to, then emails the link. Anti- enumeration: identical response regardless of account existence.

return_to is validated against GATEWAY_OAUTH_ALLOWED_RETURN_URLS (shared with hosted OAuth). A disallowed return_to is the one hard error the caller sees — it is a client misconfiguration, not an account probe, and failing closed here prevents an open redirect. An empty allowlist (the feature is unconfigured) rejects every return_to the same way.

func (*AuthService) RequestPasswordReset

func (s *AuthService) RequestPasswordReset(ctx context.Context, emailAddr string) error

RequestPasswordReset creates a password-reset token for the user matching the supplied email and dispatches a reset email.

Per OWASP guidance and the proto contract, this method always returns nil even when the email is unknown — the response time is also kept roughly equivalent so the endpoint cannot be used as an email-enumeration oracle. Errors during token persistence or email dispatch are logged internally; the caller is told nothing.

func (*AuthService) RequestPhoneVerification added in v0.14.0

func (s *AuthService) RequestPhoneVerification(ctx context.Context, userID, phoneNumber string) error

RequestPhoneVerification mints a 6-digit OTP for the user's phone and texts it. The caller must be authenticated (userID is the verified `sub`). Returns ErrSMSDisabled when phone verification is not configured, ErrPhoneAlreadyVerified when the user has already verified the same number, and ErrInvalidArgument for a malformed number. A per-user send cooldown bounds inbox/cost abuse.

func (*AuthService) SendEmailVerification

func (s *AuthService) SendEmailVerification(ctx context.Context, userID string) error

SendEmailVerification creates a verification token for the user and dispatches a verification email. Idempotent — calling it repeatedly just creates additional valid tokens (older tokens remain valid until their own expiry, on the principle that we should never invalidate a token a user might have already clicked).

func (*AuthService) VerifyEmail

func (s *AuthService) VerifyEmail(ctx context.Context, token string) (*User, error)

VerifyEmail consumes a verification token and marks the user's email as verified. Idempotent — re-verifying an already-verified user still consumes the supplied token but does not change state. Returns the updated user.

func (*AuthService) VerifyEmailLoginCode added in v0.11.0

func (s *AuthService) VerifyEmailLoginCode(ctx context.Context, emailAddr, code string, ipAddr, userAgent string) (*LoginResult, error)

VerifyEmailLoginCode validates the OTP, resolves-or-creates the user keyed by email, and issues a token pair.

Failure modes (missing/expired/consumed code, wrong code, exhausted attempts, and — when auto-create is disabled — an unknown email) all collapse to ErrEmailLoginCodeInvalid so the endpoint reveals nothing. A wrong guess bumps the per-code attempt counter; once it reaches the cap captured at mint time the code is consumed (invalidated) to stop a brute-force walk of the 6-digit space.

func (*AuthService) VerifyPhoneCode added in v0.14.0

func (s *AuthService) VerifyPhoneCode(ctx context.Context, userID, phoneNumber, code string) (*User, error)

VerifyPhoneCode validates the OTP and, on success, records the verified number on the user. The caller must be authenticated. All failure modes (missing/expired/consumed code, wrong code, exhausted attempts, or a number that does not match the one the code was minted for) collapse to ErrPhoneCodeInvalid. A wrong guess bumps the per-code attempt counter; at the cap the code is consumed so the brute-force window over the 6-digit space is bounded.

func (*AuthService) VerifyTotp

func (s *AuthService) VerifyTotp(ctx context.Context, challengeID, code, ipAddr, userAgent string) (*LoginResult, error)

VerifyTotp completes a pending login by verifying a TOTP or recovery code. Returns (user, accessToken, refreshToken, error).

func (*AuthService) VerifyTotpSetup

func (s *AuthService) VerifyTotpSetup(ctx context.Context, userID, code string) (bool, error)

VerifyTotpSetup completes TOTP enrollment by verifying a code. Returns (verified, error).

func (*AuthService) WithLoginGovernance added in v0.17.0

func (s *AuthService) WithLoginGovernance(g *LoginGovernance) *AuthService

WithLoginGovernance wires the optional login-governance bundle (the Domain/Tenant/LoginPolicy read stores) the login path consults to enforce a claimed tenant's LoginPolicy, and returns the service for chaining. app.New calls it once at construction (with the postgres stores, or nil for drivers without a governance plane). A nil bundle disables enforcement.

func (*AuthService) WithTenantAutoFormer added in v0.17.0

func (s *AuthService) WithTenantAutoFormer(af TenantAutoFormStore) *AuthService

WithTenantAutoFormer wires the optional tenant auto-formation store and returns the service for chaining. app.New calls it once at construction (with the postgres store, or nil for drivers without a control plane).

type BeginIdentityVerificationResult added in v0.4.0

type BeginIdentityVerificationResult struct {
	VerificationID string
	Provider       string
	SessionToken   string
	ExpiresAt      time.Time
}

BeginIdentityVerificationResult carries everything the connect handler needs to build a BeginIdentityVerificationResponse. The service layer keeps proto types out of its surface so the wiring is unit-testable without spinning up a Connect server.

type BootstrappedAdmin added in v1.1.0

type BootstrappedAdmin struct {
	ID                string
	Email             string
	GeneratedPassword string
}

BootstrappedAdmin is the result of CreateFirstPlatformAdmin: the created admin's id and canonical email, plus GeneratedPassword — a server-minted password shown EXACTLY once, and only when the caller supplied no password. When the caller supplied their own password, GeneratedPassword is empty.

type ControlPlaneAdminService added in v0.19.0

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

ControlPlaneAdminService provisions control-plane resources on behalf of a platform operator authenticated by a shared secret.

func NewControlPlaneAdminService added in v0.19.0

func NewControlPlaneAdminService(
	secret string,
	projects ControlPlaneProjectStore,
	tenants TenantStore,
	memberships MembershipStore,
	admins PlatformAdminStore,
	resolver DNSResolver,
	logger *zap.Logger,
) *ControlPlaneAdminService

NewControlPlaneAdminService wires the admin service. An empty secret leaves the service constructed but DISABLED: every method returns ErrUnimplemented (so the handler maps it to CodeUnimplemented). A nil logger defaults to a no-op. A nil resolver defaults to net.DefaultResolver, matching DomainService. nowFunc is injected so the auth-domain verified-at stamp is deterministic in tests; it defaults to wall-clock epoch-millis.

func (*ControlPlaneAdminService) AddProjectAuthDomain added in v1.1.0

func (s *ControlPlaneAdminService) AddProjectAuthDomain(ctx context.Context, secret, projectID, hostname string, isPrimary bool) (*RegisteredAuthDomain, error)

AddProjectAuthDomain registers a CUSTOMER-owned serving hostname on a project, UNVERIFIED, and returns the DNS TXT challenge to publish. Unlike AdminAddProjectAuthDomain (operator-vouched, seeded verified), the domain does NOT resolve requests until VerifyProjectAuthDomain proves ownership. Re-adding an already-registered hostname returns its existing record and the same deterministic challenge, so a caller can re-fetch the TXT value without a conflict. A hostname owned by a DIFFERENT project surfaces the store's ErrAlreadyExists.

isPrimary=true is rejected with ErrInvalidArgument: promoting a custom auth-domain to primary is a planned follow-up, not yet supported. A custom domain is always added NON-primary here. (Honoring it would be a half-built path: the partial-unique primary index would reject a second primary as a misleading AlreadyExists, and verification never promotes primary — so a newly-added primary could never actually become primary.)

func (*ControlPlaneAdminService) AdminAddProjectAuthDomain added in v0.19.0

func (s *ControlPlaneAdminService) AdminAddProjectAuthDomain(ctx context.Context, secret, projectID, hostname string, isPrimary bool) error

AdminAddProjectAuthDomain registers a serving hostname on a project, idempotently and seeded VERIFIED (the operator vouches for it). It lets an operator add branded/serving hostnames at runtime, complementing the GATEWAY_DEFAULT_PROJECT_AUTH_DOMAINS config-seed. A hostname already bound to a DIFFERENT project surfaces the store's conflict error.

func (*ControlPlaneAdminService) AdminAddTenantAdmin added in v0.19.0

func (s *ControlPlaneAdminService) AdminAddTenantAdmin(ctx context.Context, secret, projectID, tenantID, userID, role string) (*TenantMembership, error)

AdminAddTenantAdmin makes a user an owner/admin of a tenant (source=added), bootstrapping the first tenant administrator so a human can then self-serve (invite others, verify domains, manage members). The role defaults to owner — the highest privilege an operator would grant when standing up a tenant — and may be admin; plain member is rejected (this RPC bootstraps administration, not ordinary membership).

func (*ControlPlaneAdminService) AdminCreateProject added in v0.19.0

func (s *ControlPlaneAdminService) AdminCreateProject(ctx context.Context, secret, name, storageScopeID string) (string, error)

AdminCreateProject provisions a new control-plane project mapped onto the given physical storage scope, and returns its id. storage_scope_id is required and globally unique (a duplicate surfaces ErrAlreadyExists).

func (*ControlPlaneAdminService) AdminCreateProjectCredential added in v0.19.0

func (s *ControlPlaneAdminService) AdminCreateProjectCredential(ctx context.Context, secret, projectID, kind string) (*MintedCredential, error)

AdminCreateProjectCredential mints a lookup credential for a project. For a publishable kind it generates a public id only (no secret). For a secret kind it generates a public id AND a secret half: the secret's hash is stored, and the full "publicID.secret" raw key is returned ONCE — the only time it is ever shown, exactly like an API key. The public id is always the lookup key the project resolver matches on.

func (*ControlPlaneAdminService) AdminCreateTenant added in v0.19.0

func (s *ControlPlaneAdminService) AdminCreateTenant(ctx context.Context, secret, projectID, name, primaryDomain string) (string, error)

AdminCreateTenant provisions a tenant under a project and returns its id. An operator-created tenant is seeded CLAIMED, not latent: the operator vouches for it out-of-band, so it is immediately authoritative (its login policy and membership apply) without a domain-verification round.

func (*ControlPlaneAdminService) CreateFirstPlatformAdmin added in v1.1.0

func (s *ControlPlaneAdminService) CreateFirstPlatformAdmin(ctx context.Context, email, password string) (*BootstrappedAdmin, error)

CreateFirstPlatformAdmin is the zero-config bootstrap that establishes the FIRST platform admin on a fresh deployment. It is the one Admin RPC that is NOT secret-gated: a brand-new deployer has configured nothing yet, so gating it on GATEWAY_ADMIN_API_SECRET would make standing up the first operator impossible. Instead it is self-securing — it succeeds ONLY while the platform_admins table is empty and PERMANENTLY closes (ErrPlatformAdminExists → FailedPrecondition) once any admin exists, so it can never be replayed to escalate privilege on a provisioned deployment.

The emptiness check and the insert are one atomic, serialized store operation (admins.CreateFirstPlatformAdmin), so two concurrent bootstraps create exactly one admin and the loser is rejected — there is no check-then-write window.

When password is blank the server generates a strong one and returns it once in GeneratedPassword; when supplied it must satisfy the password strength policy (else ErrWeakPassword → InvalidArgument). Only the bcrypt hash is ever stored. When no control plane is wired (entdb/memory have no platform_admins table) it returns ErrUnimplemented.

func (*ControlPlaneAdminService) Enabled added in v0.19.0

func (s *ControlPlaneAdminService) Enabled() bool

Enabled reports whether the admin surface is active (a non-empty secret is configured). The handler consults it to return Unimplemented up front without leaking, via the secret check, whether a secret happens to match.

func (*ControlPlaneAdminService) ListProjectAuthDomains added in v1.1.0

func (s *ControlPlaneAdminService) ListProjectAuthDomains(ctx context.Context, secret, projectID string) ([]*AdminProjectAuthDomain, error)

ListProjectAuthDomains returns every auth-domain of a project, primary-first (the store's ordering), so a caller sees both verified and pending domains.

func (*ControlPlaneAdminService) VerifyProjectAuthDomain added in v1.1.0

func (s *ControlPlaneAdminService) VerifyProjectAuthDomain(ctx context.Context, secret, projectID, hostname string) (*AdminProjectAuthDomain, error)

VerifyProjectAuthDomain checks the DNS TXT ownership challenge for a project's custom auth-domain and, on success, stamps verified_at_ms so the hostname resolves. A missing/mismatched TXT record leaves the domain unverified and surfaces ErrPermissionDenied (a verification failure the caller can retry); a hostname the project does not own is ErrNotFound. An already-verified domain is idempotent — re-verifying re-checks DNS and returns the current record.

type ControlPlaneProjectStore added in v0.19.0

type ControlPlaneProjectStore interface {
	CreateProject(ctx context.Context, p *AdminProject) (string, error)
	CreateProjectCredential(ctx context.Context, c *AdminProjectCredential) (string, error)
	EnsureAuthDomain(ctx context.Context, projectID, hostname string, isPrimary bool, verifiedAtMs int64) error

	// CreateAuthDomain registers an UNVERIFIED serving hostname (verifiedAtMs
	// is 0). A hostname already bound to any project surfaces ErrAlreadyExists.
	CreateAuthDomain(ctx context.Context, projectID, hostname string, isPrimary bool) error
	// GetAuthDomain returns a project's own auth-domain, or (nil, nil) when the
	// project has no such hostname.
	GetAuthDomain(ctx context.Context, projectID, hostname string) (*AdminProjectAuthDomain, error)
	// ListAuthDomains returns every auth-domain of a project, primary-first.
	ListAuthDomains(ctx context.Context, projectID string) ([]*AdminProjectAuthDomain, error)
	// SetAuthDomainVerified stamps verifiedAtMs (> 0) on a project's own
	// auth-domain, making it resolve. A hostname the project does not own
	// surfaces ErrNotFound.
	SetAuthDomainVerified(ctx context.Context, projectID, hostname string, verifiedAtMs int64) error
}

ControlPlaneProjectStore is the narrow write side of the control-plane project registry the admin service needs. *pgrepo.ProjectStore satisfies it; injecting only this method set keeps the service decoupled from the full store surface and trivially fakeable.

EnsureAuthDomain is idempotent and seeds the domain VERIFIED at verifiedAtMs (operator-asserted — the operator vouches for the hostname, so it needs no DNS challenge). The customer-facing custom-domain methods (CreateAuthDomain / GetAuthDomain / ListAuthDomains / SetAuthDomainVerified) instead register a domain UNVERIFIED and flip it only after a DNS-TXT ownership proof.

type CreatedDomain added in v0.17.0

type CreatedDomain struct {
	Domain   *Domain
	TXTName  string
	TXTValue string
}

CreatedDomain is the result of CreateDomain: the pending domain plus the DNS TXT challenge the caller must publish before VerifyDomain succeeds. TXTName/TXTValue are empty for the email method (verified out-of-band).

type CreatedInvitation added in v0.18.0

type CreatedInvitation struct {
	Invitation *TenantInvitation
	RawToken   string
}

CreatedInvitation is the result of CreateTenantInvitation: the stored invitation plus, ONLY when no mailer is configured, the raw token (so a headless deployment can hand it to the recipient out-of-band). When a mailer delivered the invitation, RawToken is empty.

type DB

type DB interface {
	GetNode(ctx context.Context, tenantID, actor string, typeID int, nodeID string) (*entdb.Node, error)
	QueryNodes(ctx context.Context, tenantID, actor string, typeID int, filter map[string]any) ([]*entdb.Node, error)
	ExecuteAtomic(ctx context.Context, tenantID, actor string, ops []entdb.Operation) (*entdb.CommitResult, error)
	GetEdgesFrom(ctx context.Context, tenantID, actor, fromNodeID string, edgeTypeID int) ([]*entdb.Edge, error)
	GetEdgesTo(ctx context.Context, tenantID, actor, toNodeID string, edgeTypeID int) ([]*entdb.Edge, error)
	SearchNodes(ctx context.Context, tenantID, actor string, typeID int, query string) ([]*entdb.Node, error)
	// RegisterUserInTenant registers userID in the global user
	// registry and adds them as a member of tenantID with the given
	// role. Idempotent: tolerates ALREADY_EXISTS on both calls. The
	// service-layer admin path (admin.InviteUser) uses this to bring
	// new users onto v1.12+'s "actor must be a tenant member"
	// contract; the typed entRepository.CreateUser path uses the
	// equivalent SDK calls directly via its entClient seam.
	RegisterUserInTenant(ctx context.Context, tenantID, userID, email, name, role string) error
}

DB is the subset of the EntDB Transport used by identity services.

func ScopedDB added in v1.1.0

func ScopedDB(ctx context.Context, bootDB DB, defaultProjectID string) (DB, string)

ScopedDB resolves a request's project from ctx and returns bootDB bound to that project together with the resolved project id. It is the exported pair of scopedDB + requestProjectID, wired by internal/app into the audit logger so an audit write lands under the SAME project the request resolved to (ADR-0002): the project id partitions the entdb transport (which keys on the per-call tenant argument every method already takes), and the returned DB is the project-bound postgres writer (which ignores that argument and filters on its bound project). Reads via ProfileService.ListAuditEvents resolve the project identically, so writes and reads round-trip under one project.

type DNSResolver added in v0.18.0

type DNSResolver interface {
	LookupTXT(ctx context.Context, host string) ([]string, error)
}

DNSResolver looks up DNS TXT records. It is the single I/O boundary of VerifyDomain, injected so callers (and tests) can supply the lookup implementation instead of always hitting real DNS. *net.Resolver satisfies it; the embedding API (identityserver.Options.DNSResolver) threads a custom one through for full-stack tests against a real DB.

type Domain added in v0.17.0

type Domain struct {
	ID                 string
	ProjectID          string
	TenantID           string
	Domain             string
	VerificationMethod string
	Status             string
	VerifiedAtMs       int64
	CreatedAtMs        int64
	UpdatedAtMs        int64
}

Domain is an email domain bound to a Tenant within a Project. Verifying it (DNS TXT or email) flips its status to `verified` and claims the owning tenant. (project_id, lower(domain)) is unique — one tenant per email domain within a project.

type DomainService added in v0.17.0

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

DomainService verifies tenant email domains within a project.

func NewDomainService added in v0.17.0

func NewDomainService(
	domains DomainStore,
	tenants TenantStore,
	memberships MembershipStore,
	resolver DNSResolver,
	cfg *config.Config,
	logger *zap.Logger,
) *DomainService

NewDomainService wires a DomainService. resolver may be nil, in which case net.DefaultResolver is used — callers inject a custom resolver to drive the TXT-present / TXT-absent paths without touching real DNS.

func (*DomainService) CreateDomain added in v0.17.0

func (s *DomainService) CreateDomain(ctx context.Context, callerID, tenantID, domain, method string) (*CreatedDomain, error)

CreateDomain registers a pending email domain on a tenant. callerID must already be an owner/admin member of the tenant. For the DNS-TXT method it returns the deterministic challenge (TXT name + value) the caller publishes; VerifyDomain later checks for it.

func (*DomainService) ListTenantDomains added in v0.17.0

func (s *DomainService) ListTenantDomains(ctx context.Context, callerID, tenantID string) ([]*Domain, error)

ListTenantDomains returns every domain bound to a tenant. callerID must be an owner/admin member of the tenant.

func (*DomainService) VerifyDomain added in v0.17.0

func (s *DomainService) VerifyDomain(ctx context.Context, callerID, domainID string) (*Domain, error)

VerifyDomain proves control of a pending domain and, on success, marks the domain verified, claims its tenant, and makes the caller an owner.

AuthZ: callerID must be an owner/admin member of the tenant — EXCEPT when the tenant is still latent with no members yet, in which case verification is open and the first verifier becomes its owner.

Only the DNS-TXT method is implemented; the email method returns ErrUnimplemented rather than faking success.

type DomainStore added in v0.17.0

type DomainStore interface {
	// CreateDomain inserts a domain. ProjectID, TenantID and Domain are
	// required; a blank id is generated and written back. A duplicate
	// (project_id, lower(domain)) surfaces ErrAlreadyExists.
	CreateDomain(ctx context.Context, d *Domain) (string, error)
	// GetDomain returns the domain by id within a project, or (nil, nil).
	GetDomain(ctx context.Context, projectID, domainID string) (*Domain, error)
	// GetDomainByName returns the domain row for a name (case-insensitive)
	// within a project, or (nil, nil).
	GetDomainByName(ctx context.Context, projectID, domain string) (*Domain, error)
	// SetDomainStatus transitions a domain's status and, when verifying,
	// stamps verified_at_ms (pass 0 to default to now on a verify). Unknown
	// ids are a no-op.
	SetDomainStatus(ctx context.Context, projectID, domainID, status string, verifiedAtMs int64) error
	// ListDomainsByTenant returns every domain bound to a tenant, newest
	// first.
	ListDomainsByTenant(ctx context.Context, projectID, tenantID string) ([]*Domain, error)
}

DomainStore persists Domains within a Project. Reads that miss return (nil, nil), never an error.

type EmailChangeToken

type EmailChangeToken struct {
	NodeID     string
	TokenHash  string
	UserID     string
	OldEmail   string
	NewEmail   string
	ExpiresAt  int64 // epoch ms
	CreatedAt  int64 // epoch ms
	ConsumedAt int64 // epoch ms; 0 = unconsumed
}

EmailChangeToken represents a pending primary-email rotation. The token is created at request time (after re-auth) and consumed when the user clicks the verification link sent to the *new* address.

type EmailLoginCodeRecord added in v0.11.0

type EmailLoginCodeRecord struct {
	NodeID       string
	Email        string
	CodeHash     string
	ExpiresAt    int64 // epoch ms
	CreatedAt    int64 // epoch ms
	ConsumedAt   int64 // epoch ms; 0 = unconsumed
	AttemptCount int64
	MaxAttempts  int64
}

EmailLoginCodeRecord is the OTP arm of passwordless email login. The record is keyed by Email (at most one live code per address); a new request overwrites the previous one. CodeHash is sha256 of the 6-digit code. AttemptCount tracks failed verifies; once it reaches MaxAttempts the code is invalidated (brute-force cap). The record carries no user id — the account may not exist until VerifyEmailLoginCode resolves or creates it.

type EmailVerificationToken

type EmailVerificationToken struct {
	NodeID     string
	TokenHash  string
	UserID     string
	Email      string
	ExpiresAt  int64 // epoch ms
	CreatedAt  int64 // epoch ms
	ConsumedAt int64 // epoch ms; 0 = unconsumed
}

EmailVerificationToken represents a stored email-verification token.

type Group

type Group struct {
	ID          string
	Name        string
	Description string
	CreatedAt   int64
	UpdatedAt   int64
}

Group is the domain representation of a working group (type_id 2).

type GroupService

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

GroupService implements working-group CRUD and membership operations. The underlying EntDB node type is WorkingGroup (type_id 2).

func NewGroupService

func NewGroupService(db DB, projectID string, auditLog *audit.Logger, logger *zap.Logger) *GroupService

NewGroupService creates a GroupService.

func (*GroupService) AddGroupMember

func (s *GroupService) AddGroupMember(ctx context.Context, actorID, groupID, userID string) error

AddGroupMember creates a MEMBER_OF edge from user to group.

func (*GroupService) CreateGroup

func (s *GroupService) CreateGroup(ctx context.Context, actorID, name, description string) (*Group, error)

CreateGroup creates a new working group.

func (*GroupService) DeleteGroup

func (s *GroupService) DeleteGroup(ctx context.Context, actorID, groupID string) error

DeleteGroup deletes a working group.

func (*GroupService) ListGroupMembers

func (s *GroupService) ListGroupMembers(ctx context.Context, actorID, groupID string) ([]*User, error)

ListGroupMembers returns all users that belong to a group via MEMBER_OF edges.

func (*GroupService) ListGroups

func (s *GroupService) ListGroups(ctx context.Context, actorID, cursor string, limit int) ([]*Group, string, error)

ListGroups returns a paginated list of groups.

func (*GroupService) RemoveGroupMember

func (s *GroupService) RemoveGroupMember(ctx context.Context, actorID, groupID, userID string) error

RemoveGroupMember deletes the MEMBER_OF edge from user to group.

func (*GroupService) UpdateGroup

func (s *GroupService) UpdateGroup(ctx context.Context, actorID, groupID, name, description string) (*Group, error)

UpdateGroup patches name and/or description of a group.

type HelpRequest

type HelpRequest struct {
	ID              string
	Email           string
	Reason          string
	SourceIP        string
	UserAgent       string
	Status          string
	ResolvedBy      string
	ResolutionNotes string
	ResolvedAt      int64
	CreatedAt       int64
}

HelpRequest is the domain representation of an admin help request (type_id 28).

type HelpService

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

HelpService implements the admin-help-request flow. End users who cannot log in raise a help request; admins resolve or reject them from the dashboard.

func NewHelpService

func NewHelpService(db DB, projectID string, auditLog *audit.Logger, logger *zap.Logger) *HelpService

NewHelpService creates a HelpService.

func (*HelpService) ListHelpRequests

func (s *HelpService) ListHelpRequests(
	ctx context.Context, actorID, statusFilter, cursor string, limit int,
) ([]*HelpRequest, string, int, error)

ListHelpRequests returns a paginated list of help requests. Admin-only. Returns pendingCount (all pending, regardless of filter).

func (*HelpService) RequestAdminHelp

func (s *HelpService) RequestAdminHelp(
	ctx context.Context, email, reason, sourceIP, userAgent string,
) error

RequestAdminHelp creates a help request. Always returns nil error to prevent email enumeration — internal failures are logged and swallowed. Rate limited to 3 requests per email per 24 hours.

func (*HelpService) ResolveHelpRequest

func (s *HelpService) ResolveHelpRequest(
	ctx context.Context, actorID, requestID string, reject bool, notes string,
) (*HelpRequest, error)

ResolveHelpRequest marks a help request as resolved or rejected. Admin-only.

type HostedOAuthBeginResult added in v0.9.0

type HostedOAuthBeginResult struct {
	AuthorizationURL string
}

HostedOAuthBeginResult is the output of BeginHostedOAuth: the provider authorization URL the browser should be 302-redirected to. The state + PKCE verifier + return_to are all sealed inside the signed hosted state token carried in the URL's `state` parameter, so the callback needs nothing else from the browser.

type HostedOAuthCallbackResult added in v0.9.0

type HostedOAuthCallbackResult struct {
	ReturnTo string
	Code     string
}

HostedOAuthCallbackResult is the output of CompleteHostedOAuth: the validated return_to plus the freshly-minted one-time code the callback appends as ?code=<otc>.

type IdentityVerificationRecord added in v0.4.0

type IdentityVerificationRecord struct {
	NodeID            string
	VerificationID    string // public identifier returned to clients
	UserID            string
	ProjectID         string // storage shard (ADR-0002): the per-request project
	Provider          string
	ProviderSessionID string
	Status            string // one of IDVStatus* constants
	CreatedAt         int64  // epoch ms
	UpdatedAt         int64  // epoch ms
	CompletedAt       int64  // epoch ms; 0 if not yet completed
	RejectionReason   string // empty unless Status == IDVStatusRejected
}

IdentityVerificationRecord represents a single verification session (document + selfie) tracked by the service. The provider field names the backing implementation (e.g. "azure", "stub"); ProviderSessionID is the provider's own identifier for the check.

type IdentityVerificationService added in v0.4.0

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

IdentityVerificationService orchestrates document + selfie verification through a pluggable idv.Provider, persisting one IdentityVerificationRecord per session against the user.

The caller-facing identifier (VerificationID) is server-issued; the provider's own session id is stored alongside but never returned to the client.

func NewIdentityVerificationService added in v0.4.0

func NewIdentityVerificationService(
	repo Repository,
	provider idv.Provider,
	projectID string,
	logger *zap.Logger,
) *IdentityVerificationService

NewIdentityVerificationService constructs the service. clock and newID default to time.Now / a hex random id when nil.

func (*IdentityVerificationService) BeginIdentityVerification added in v0.4.0

func (s *IdentityVerificationService) BeginIdentityVerification(
	ctx context.Context,
	userID string,
) (*BeginIdentityVerificationResult, error)

BeginIdentityVerification creates a new verification session for the given user. It allocates a server-side VerificationID, asks the provider to mint a client session token, and persists a PENDING record so subsequent status polls have something to read.

Returns ErrNotFound when userID does not resolve to a user.

func (*IdentityVerificationService) GetIdentityVerificationStatus added in v0.4.0

func (s *IdentityVerificationService) GetIdentityVerificationStatus(
	ctx context.Context,
	callerUserID, verificationID string,
) (*IdentityVerificationRecord, error)

GetIdentityVerificationStatus returns the current state of a verification. When verificationID is empty the caller's latest session is returned; this matches the "what's my status?" query the client SDK makes most often.

For sessions whose persisted state is non-terminal, the provider is consulted and the local record is updated if a verdict has arrived.

Returns ErrNotFound when no matching session exists, and ErrPermissionDenied when the caller is not the owner.

type InitiateQrLoginResult added in v0.6.0

type InitiateQrLoginResult struct {
	SessionID  string
	QRURL      string
	PollSecret string
	ExpiresIn  int32
}

InitiateQrLoginResult is the value returned by InitiateQrLogin.

type InvitationRecord

type InvitationRecord struct {
	NodeID     string
	TokenHash  string
	Email      string
	UserID     string
	InvitedBy  string
	Role       string
	ExpiresAt  int64
	AcceptedAt int64
	CreatedAt  int64
}

InvitationRecord represents a user invitation.

type InvitationStore added in v0.17.0

type InvitationStore interface {
	// CreateInvitation atomically enforces one-open-invite: in a single
	// transaction it revokes any existing pending invitation for the same
	// (project, tenant, lower(email)) and inserts the new one. A blank id
	// is generated and written back. Returns the new invitation id.
	CreateInvitation(ctx context.Context, inv *TenantInvitation) (string, error)
	// GetInvitationByTokenHash resolves an invitation by its hashed token
	// within a project, or (nil, nil).
	GetInvitationByTokenHash(ctx context.Context, projectID, tokenHash string) (*TenantInvitation, error)
	// SetInvitationStatus transitions an invitation's status and, when
	// accepting, stamps accepted_at_ms (0 defaults to now on accept).
	// Unknown ids are a no-op.
	SetInvitationStatus(ctx context.Context, projectID, invitationID, status string, acceptedAtMs int64) error
	// ListInvitationsForTenant returns every invitation in a tenant,
	// newest first.
	ListInvitationsForTenant(ctx context.Context, projectID, tenantID string) ([]*TenantInvitation, error)
}

InvitationStore persists TenantInvitations within a Project. Reads that miss return (nil, nil).

type InviteResult

type InviteResult struct {
	User              *User
	InvitationToken   string
	SetupURL          string
	TemporaryPassword string
}

InviteResult is returned by AdminService.InviteUser.

type LoginChallengeRecord

type LoginChallengeRecord struct {
	NodeID      string
	ChallengeID string
	UserID      string
	ExpiresAt   int64
	CreatedAt   int64
}

LoginChallengeRecord represents a pending 2FA login challenge.

type LoginGovernance added in v0.17.0

type LoginGovernance struct {
	Domains  DomainStore
	Tenants  TenantStore
	Policies LoginPolicyStore
}

LoginGovernance is the read-side bundle the login path consults to enforce a claimed tenant's LoginPolicy. It is postgres-only governance state, set once via AuthService.WithLoginGovernance; drivers without a governance plane (entdb/memory) leave it nil and impose no restriction.

It is deliberately read-only — enforcement never mutates governance state — and groups the three stores the lookup walks (domain → tenant → policy) so the dependency stays a single optional field on the already-wide service.

type LoginPolicy added in v0.17.0

type LoginPolicy struct {
	ID        string
	ProjectID string
	TenantID  string
	// AllowedMethods is a comma-separated allow-list of login method
	// tokens (see LoginMethod*). Empty means no restriction.
	AllowedMethods string
	// SSORequired forces SSO; SSOConnectionJSON carries the connection
	// config (IdP metadata) as a JSON object.
	SSORequired       bool
	SSOConnectionJSON string
	// Require2FA forces a second factor after the primary method.
	Require2FA  bool
	CreatedAtMs int64
	UpdatedAtMs int64
}

LoginPolicy is a claimed tenant's authentication policy.

type LoginPolicyStore added in v0.17.0

type LoginPolicyStore interface {
	// UpsertLoginPolicy inserts or replaces the policy for
	// (ProjectID, TenantID). Both are required. Returns the row id.
	UpsertLoginPolicy(ctx context.Context, p *LoginPolicy) (string, error)
	// GetLoginPolicy returns the policy for (projectID, tenantID), or
	// (nil, nil) when none is set.
	GetLoginPolicy(ctx context.Context, projectID, tenantID string) (*LoginPolicy, error)
}

LoginPolicyStore persists at most one LoginPolicy per (project, tenant).

type LoginResult

type LoginResult struct {
	User             *User
	AccessToken      string
	RefreshToken     string
	ExpiresIn        int32
	TotpRequired     bool
	LoginChallengeID string
}

LoginResult is returned by password/OAuth login when successful.

type MagicLinkResult added in v0.11.0

type MagicLinkResult struct {
	*LoginResult
	ReturnTo string
}

MagicLinkResult is a LoginResult plus the allowlist-validated app URL the SPA should navigate to after a successful redeem.

type MagicLinkTokenRecord added in v0.11.0

type MagicLinkTokenRecord struct {
	NodeID     string
	TokenHash  string
	Email      string
	ReturnTo   string
	ExpiresAt  int64 // epoch ms
	CreatedAt  int64 // epoch ms
	ConsumedAt int64 // epoch ms; 0 = unconsumed
}

MagicLinkTokenRecord is the magic-link arm of passwordless email login. TokenHash is sha256 of a high-entropy opaque token; the row is bound to the requested Email and the allowlist-validated ReturnTo. Single-use is enforced by the ConsumedAt compare-and-set.

type MembershipService added in v0.18.0

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

MembershipService manages tenant invitations and memberships within a project.

func NewMembershipService added in v0.18.0

func NewMembershipService(
	invitations InvitationStore,
	memberships MembershipStore,
	tenants TenantStore,
	users UserDirectory,
	mailer email.Transport,
	mailerConfigured bool,
	cfg *config.Config,
	logger *zap.Logger,
) *MembershipService

NewMembershipService wires a MembershipService. mailer must be non-nil (the app builds a log-only transport when no provider is configured); mailerConfigured tells the service whether that transport actually delivers, which governs whether the raw token is returned in the RPC response. A nil logger defaults to a no-op.

func (*MembershipService) AcceptTenantInvitation added in v0.18.0

func (s *MembershipService) AcceptTenantInvitation(ctx context.Context, callerID, rawToken string) (*TenantMembership, error)

AcceptTenantInvitation redeems a raw invitation token for the authenticated caller. It validates the token (must be pending and unexpired), enforces that the caller's account email equals the invitation email, then upserts an active invited-source membership at the invitation's role and marks the invitation accepted.

Email-match policy: a leaked token must not let the wrong account join. The caller is looked up and their email compared (case-insensitively) to the invitation email; a mismatch is PermissionDenied. An expired invitation is marked expired and rejected; an unknown/revoked/already-accepted token is rejected without mutation.

func (*MembershipService) CreateTenantInvitation added in v0.18.0

func (s *MembershipService) CreateTenantInvitation(ctx context.Context, callerID, tenantID, emailAddr, role string) (*CreatedInvitation, error)

CreateTenantInvitation invites an email address to join a tenant. callerID must be an active owner/admin member of the tenant. It generates a random token, stores its hash via InvitationStore.CreateInvitation (which atomically revokes any prior open invite for the same recipient), and best-effort dispatches a branded invitation email carrying the raw token.

func (*MembershipService) ListTenantInvitations added in v0.18.0

func (s *MembershipService) ListTenantInvitations(ctx context.Context, callerID, tenantID string) ([]*TenantInvitation, error)

ListTenantInvitations returns every invitation in a tenant, newest first. callerID must be an active owner/admin member of the tenant.

func (*MembershipService) ListTenantMembers added in v0.18.0

func (s *MembershipService) ListTenantMembers(ctx context.Context, callerID, tenantID string) ([]*TenantMembership, error)

ListTenantMembers returns every membership in a tenant. callerID must be an active owner/admin member of the tenant.

func (*MembershipService) RemoveTenantMember added in v0.18.0

func (s *MembershipService) RemoveTenantMember(ctx context.Context, callerID, tenantID, targetUserID string) error

RemoveTenantMember removes a user's membership from a tenant. callerID must be an active owner/admin member of the tenant.

Last-owner guard: a tenant must never be left ownerless. Removing the only remaining active owner is rejected with FailedPrecondition-style ErrInvalidArgument-adjacent semantics — here ErrPermissionDenied is wrong (the caller IS permitted to remove members), so we surface a dedicated guard error. The rule: if the target is an active owner and is the last active owner of the tenant, the removal is refused. Removing a non-owner, or an owner while other active owners remain, is allowed (including an owner removing themselves, as long as another owner survives).

type MembershipStore added in v0.17.0

type MembershipStore interface {
	// UpsertMembership inserts or, on a (project, tenant, user) conflict,
	// updates source/role/status in place (keeping id + created_at_ms).
	// Returns the surviving row id.
	UpsertMembership(ctx context.Context, m *TenantMembership) (string, error)
	// GetMembership returns the membership for (project, tenant, user), or
	// (nil, nil).
	GetMembership(ctx context.Context, projectID, tenantID, userID string) (*TenantMembership, error)
	// ListMembershipsForUser returns every membership a user holds across
	// the tenants of a project — the set the auth middleware checks.
	ListMembershipsForUser(ctx context.Context, projectID, userID string) ([]*TenantMembership, error)
	// ListMembershipsForTenant returns every membership in a tenant.
	ListMembershipsForTenant(ctx context.Context, projectID, tenantID string) ([]*TenantMembership, error)
	// RemoveMembership deletes the membership for (project, tenant, user).
	// Unknown rows are a no-op.
	RemoveMembership(ctx context.Context, projectID, tenantID, userID string) error
}

MembershipStore persists TenantMemberships within a Project. Reads that miss return (nil, nil).

type MintedCredential added in v0.19.0

type MintedCredential struct {
	ID       string
	PublicID string
	RawKey   string
}

MintedCredential is the result of AdminCreateProjectCredential: the stored row's id and public lookup id, plus the RAW key shown exactly once. RawKey is empty for a publishable kind (which has no secret half).

type OAuthBeginResult

type OAuthBeginResult struct {
	AuthorizationURL string
	State            string
	StateToken       string
	CodeVerifier     string
	ExpiresIn        int32
}

OAuthBeginResult carries the provider authorization URL and the server-minted state artifacts needed to complete the OAuth flow.

type OAuthIdentity

type OAuthIdentity struct {
	NodeID          string
	UserID          string
	Provider        string
	ProviderUserID  string
	EmailAtLinkTime string
	CreatedAt       int64 // epoch ms
}

OAuthIdentity is the persisted link between a local User and a provider-side stable identity (provider, provider_user_id).

Lookups by (provider, provider_user_id) are what make OAuth login resilient to a provider-side email change: the user keeps the same local account even if their gmail address changes.

Composite uniqueness on (provider, provider_user_id) is enforced at the application layer — EntDB does not currently expose composite unique constraints. CreateOAuthIdentity callers must lookup first.

type OAuthOneTimeCodeRecord added in v0.9.0

type OAuthOneTimeCodeRecord struct {
	NodeID     string
	CodeHash   string
	UserID     string
	ExpiresAt  int64 // epoch ms
	CreatedAt  int64 // epoch ms
	ConsumedAt int64 // epoch ms; 0 = unconsumed
}

OAuthOneTimeCodeRecord is the single-use handover artifact for the hosted OAuth flow. The hosted callback stores the SHA-256 hash of an opaque code keyed to the authenticated user; RedeemOAuthCode consumes it (consumed_at CAS from 0) and mints a fresh token pair. Only the user id is persisted — no token material is stored at rest.

type PasskeyChallengeRecord

type PasskeyChallengeRecord struct {
	NodeID        string
	Challenge     string // base64url
	UserID        string
	ChallengeType string // "registration" or "authentication"
	ExpiresAt     int64
	CreatedAt     int64
}

PasskeyChallengeRecord represents a stored passkey challenge.

type PasskeyCredRecord

type PasskeyCredRecord struct {
	NodeID       string
	CredentialID string
	UserID       string
	PublicKey    string
	SignCount    int64
	DeviceName   string
	AAGUID       string
	Transports   string
	CreatedAt    int64
	LastUsedAt   int64
}

PasskeyCredRecord represents a stored passkey credential.

type PasskeyInfo

type PasskeyInfo struct {
	CredentialID string
	DeviceName   string
	CreatedAt    time.Time
	LastUsedAt   time.Time
}

PasskeyInfo holds display-safe passkey credential metadata.

type PasswordResetToken

type PasswordResetToken struct {
	NodeID     string
	TokenHash  string
	UserID     string
	ExpiresAt  int64 // epoch ms
	CreatedAt  int64 // epoch ms
	ConsumedAt int64 // epoch ms; 0 = unconsumed
}

PasswordResetToken represents a stored password-reset token.

type PhoneVerificationCodeRecord added in v0.14.0

type PhoneVerificationCodeRecord struct {
	NodeID       string
	UserID       string
	PhoneNumber  string
	CodeHash     string
	ExpiresAt    int64 // epoch ms
	CreatedAt    int64 // epoch ms
	ConsumedAt   int64 // epoch ms; 0 = unconsumed
	AttemptCount int64
	MaxAttempts  int64
}

PhoneVerificationCodeRecord is the SMS-OTP arm of phone-ownership verification. The record is keyed by UserID (at most one live code per user); a new request overwrites the previous one. CodeHash is sha256 of the 6-digit code. AttemptCount tracks failed verifies; once it reaches MaxAttempts the code is invalidated (brute-force cap). Unlike EmailLoginCodeRecord this always carries a UserID — the caller is an already-authenticated user proving ownership of PhoneNumber.

type PlatformAdmin added in v1.1.0

type PlatformAdmin struct {
	ID            string
	Email         string
	PasswordHash  string
	TOTPRequired  bool
	Status        string
	CreatedAtMs   int64
	LastLoginAtMs int64
}

PlatformAdmin is a control-plane operator account. Email is unique (case-insensitive); only PasswordHash — never a raw password — is stored.

type PlatformAdminStore added in v1.1.0

type PlatformAdminStore interface {
	// CreateFirstPlatformAdmin inserts a the first platform admin ATOMICALLY
	// and ONLY while the table is empty. It is the storage primitive behind
	// the zero-config bootstrap: the emptiness check and the insert happen in
	// one serialized transaction so two concurrent bootstraps create exactly
	// one admin.
	//
	// It returns (created=true, nil) when this call inserted the admin, and
	// (created=false, nil) when an admin already existed (the table was not
	// empty) — the bootstrap is then permanently closed. A blank id is
	// generated and written back to a.ID on success. Any other failure (e.g.
	// a duplicate-email race) is returned as an error.
	CreateFirstPlatformAdmin(ctx context.Context, a *PlatformAdmin) (created bool, err error)

	// CountPlatformAdmins returns the number of platform admins. It backs the
	// store conformance assertions; the bootstrap itself relies on the atomic
	// CreateFirstPlatformAdmin, not a read-then-write.
	CountPlatformAdmins(ctx context.Context) (int, error)
}

PlatformAdminStore persists PlatformAdmins. It exists only on the postgres control-plane driver; entdb/memory have no platform_admins table, so the admin service is constructed without it and the bootstrap RPC returns Unimplemented.

type PollQrResult

type PollQrResult struct {
	Status       string
	User         *User
	AccessToken  string
	RefreshToken string
	ExpiresIn    int32
}

PollQrResult holds the result of polling a QR login session.

type ProfileService

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

ProfileService implements self-service profile, session, passkey, password, and audit log operations.

func NewProfileService

func NewProfileService(repo Repository, db DB, projectID string, auditLog *audit.Logger, logger *zap.Logger) *ProfileService

NewProfileService creates a ProfileService.

The repo argument may be nil — in that case only the DB-backed paths work (Sessions, AuditEvents). Profile/password operations need repo to be set; nil triggers ErrServiceUnavailable at call time, the same shape the no-persistence stub takes.

func (*ProfileService) ChangePassword

func (s *ProfileService) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) error

ChangePassword changes the user's password. Requires current password verification. Invalidates all refresh tokens.

func (*ProfileService) DeletePasskey

func (s *ProfileService) DeletePasskey(ctx context.Context, userID, credentialID string) error

DeletePasskey deletes a passkey belonging to the user.

func (*ProfileService) ListAuditEvents

func (s *ProfileService) ListAuditEvents(
	ctx context.Context,
	actorID, targetID, eventType string,
	startTime, endTime int64,
	cursor string, limit int,
) ([]*AuditEvent, string, error)

ListAuditEvents queries audit events. Admin-only.

func (*ProfileService) ListMyPasskeys

func (s *ProfileService) ListMyPasskeys(ctx context.Context, userID string) ([]*PasskeyInfo, error)

ListMyPasskeys returns all passkey credentials for the user.

func (*ProfileService) ListMySessions

func (s *ProfileService) ListMySessions(ctx context.Context, userID string) ([]*Session, error)

ListMySessions returns all active sessions for the user.

func (*ProfileService) RevokeAllSessions

func (s *ProfileService) RevokeAllSessions(ctx context.Context, userID, password string) (int, error)

RevokeAllSessions revokes every session for the user. Requires password confirmation.

func (*ProfileService) RevokeSession

func (s *ProfileService) RevokeSession(ctx context.Context, userID, sessionID string) error

RevokeSession revokes a specific session. The session must belong to the calling user.

func (*ProfileService) UpdateProfile

func (s *ProfileService) UpdateProfile(ctx context.Context, userID, name, avatarURL string) (*User, error)

UpdateProfile updates the authenticated user's name and/or avatar. Routes through the Repository interface (which every backend implements fully) rather than the low-level DB.GetNode/ExecuteAtomic pair (which the memory backend stubs out as ErrServiceUnavailable).

type ProjectCORSConfig added in v1.1.0

type ProjectCORSConfig struct {
	AllowedOrigins []string `json:"allowed_origins"`
}

ProjectCORSConfig is the per-project CORS policy. AllowedOrigins is layered on top of the global GATEWAY_ALLOWED_ORIGINS floor: a browser origin is accepted when it is in either set. Each entry must be a bare scheme+host(+port) origin (no path/query/fragment, lower-case http:// or https:// scheme); the project resolver validates them with middleware.ParseAllowedOrigins before they reach a request.

type ProjectConfig added in v1.1.0

type ProjectConfig struct {
	// CORS holds the project's browser cross-origin policy.
	CORS ProjectCORSConfig `json:"cors"`
}

ProjectConfig is the typed view of a project's config_json blob. It is the single decode target for everything an operator can configure per project in the control plane, so callers never reach into a raw map. New per-project knobs are added as fields here, not as scattered map lookups.

Unknown keys are tolerated (forward compatibility): a config written by a newer server still decodes on an older one, ignoring fields it does not understand.

func ParseProjectConfig added in v1.1.0

func ParseProjectConfig(configJSON string) (ProjectConfig, error)

ParseProjectConfig decodes a project's config_json into the typed ProjectConfig. An empty or all-whitespace blob is the zero config (no per-project overrides) — the same meaning the store gives "" by normalising it to "{}". A malformed blob is a configuration error the caller must surface, never silently swallow.

type ProjectResolver added in v0.16.0

type ProjectResolver interface {
	// ResolveByCredential resolves the active project a credential public
	// id belongs to.
	ResolveByCredential(ctx context.Context, publicID string) (*ResolvedProject, error)
	// ResolveByHostname resolves the active project a serving hostname maps
	// onto (case-insensitive).
	ResolveByHostname(ctx context.Context, hostname string) (*ResolvedProject, error)
}

ProjectResolver resolves a request's project from the credentials it carries. It is implemented by the control-plane store (postgres only); deployments whose driver has no control plane pass a nil resolver and the middleware pins every request to the default project.

Both methods return (nil, nil) for a clean miss — an unknown key or an unmapped hostname — and a non-nil error only for an infrastructure failure. A resolver must NOT resolve a suspended project or a revoked credential; those are misses.

type ProjectScope added in v0.16.0

type ProjectScope struct {
	// ProjectID is the resolved control-plane project id.
	ProjectID string

	// StorageScopeID is the physical storage scope the project maps onto.
	// It is distinct from ProjectID (a project is a logical entity that
	// points at a storage scope) and must not be conflated with it.
	StorageScopeID string

	// PrimaryAuthDomain is the project's primary serving hostname, when one
	// is configured. The service builds branded links (email verification,
	// password reset, magic-link, …) from it so a user sees a URL on the
	// product's own domain. Empty when the project has no auth-domain, in
	// which case the service falls back to its configured base URL.
	PrimaryAuthDomain string

	// CORSAllowedOrigins is the project's own browser CORS allow-list,
	// parsed and validated from its config_json. It is layered on top of
	// the global GATEWAY_ALLOWED_ORIGINS floor by the CORS middleware: a
	// request whose Origin is in either set is allowed. Empty when the
	// project configures none, in which case only the global floor applies.
	CORSAllowedOrigins []string
}

ProjectScope is the per-request project binding carried in the request context by the project-resolution middleware. A Project is the redesign's top-level isolation entity (Firebase-project equivalent): it is resolved from a request's credential key or its Host header (via an auth-domain), ahead of any tenant resolution.

In a zero-config single-project deployment every request resolves to the default project, so the scope is always present once the middleware is installed; nothing downstream is forced to special-case its absence.

func ProjectScopeFromContext added in v0.16.0

func ProjectScopeFromContext(ctx context.Context) *ProjectScope

ProjectScopeFromContext returns the per-request project scope, or nil when none was injected (any code path that runs before resolution, or a deployment whose driver has no control plane).

type QrLoginSessionRecord

type QrLoginSessionRecord struct {
	NodeID             string
	SessionID          string
	Status             string
	UserID             string
	NewDeviceInfo      string
	NewDeviceIP        string
	NewDeviceUserAgent string
	ApprovedDeviceInfo string
	// PollSecretHash is sha256(poll_secret) where poll_secret is returned
	// only to the initiating device by InitiateQrLogin. PollQrLogin must
	// present the matching plaintext or the session looks "expired".
	PollSecretHash string
	ExpiresAt      int64
	CreatedAt      int64
	UpdatedAt      int64
}

QrLoginSessionRecord represents a stored QR login session.

type QrSessionInfo

type QrSessionInfo struct {
	Status        string
	NewDeviceInfo string
	NewDeviceIP   string
	ExpiresAt     time.Time
}

QrSessionInfo holds the public details of a QR login session.

type RecoveryCodeRecord

type RecoveryCodeRecord struct {
	NodeID    string
	UserID    string
	CodeHash  string
	Used      bool
	CreatedAt int64
	UsedAt    int64
}

RecoveryCodeRecord represents a stored recovery code.

type RefreshTokenRecord

type RefreshTokenRecord struct {
	NodeID       string
	TokenHash    string
	UserID       string
	DeviceInfo   string
	DeviceName   string
	IPAddress    string
	UserAgent    string
	ExpiresAt    int64 // epoch ms
	CreatedAt    int64
	LastUsedAt   int64
	ConsumedAtMs int64 // epoch ms; 0 = unconsumed (still valid for refresh)
}

RefreshTokenRecord represents a stored refresh token.

type RegisteredAuthDomain added in v1.1.0

type RegisteredAuthDomain struct {
	Domain   *AdminProjectAuthDomain
	TXTName  string
	TXTValue string
}

RegisteredAuthDomain is the result of AddProjectAuthDomain: the registered (still-UNVERIFIED) domain plus the deterministic DNS TXT challenge the caller must publish before VerifyProjectAuthDomain succeeds.

type Repository

type Repository interface {
	// Users
	FindUserByEmail(ctx context.Context, email string) (*User, error)
	GetUser(ctx context.Context, userID string) (*User, error)
	CreateUser(ctx context.Context, u *User) (string, error) // returns node ID
	UpdateUser(ctx context.Context, userID string, fields map[string]any) error
	// DeleteUser physically removes the user and every user_id-keyed
	// record, synchronously, on every driver. This covers the durable
	// identity/auth material — sessions, refresh tokens, oauth identities,
	// passkey credentials, totp credentials, recovery codes, identity
	// verifications, and phone verification codes — and, as of #168, the
	// short-lived tokens too: password-reset, email-verification/change,
	// passkey and login challenges, qr sessions, oauth one-time codes, and
	// invitations.
	// After it, GetUser returns nil and the email is reusable for a new
	// CreateUser.
	//
	// The only artifacts NOT removed synchronously are the email-keyed
	// login codes / magic-link tokens, which carry no user_id and so
	// cannot be enumerated per-user; they are reaped by the TTL sweepers —
	// safe, since they expire quickly, reference a now-deleted user, and
	// never block email reuse. audit_events are retained for
	// accountability. Idempotent: deleting a non-existent user returns nil.
	//
	// (Invitations are drained but are the one user_id-keyed type the
	// cross-driver conformance suite cannot seed/assert — Repository
	// exposes no invitation create method; they are written via the entdb
	// graph.)
	DeleteUser(ctx context.Context, userID string) error

	// Lockout state. These are dedicated methods (rather than UpdateUser
	// patches) so the persistence layer can implement them as single
	// atomic writes — important for racing concurrent failed-login
	// attempts on the same account.
	IncrementFailedLoginCount(ctx context.Context, userID string) (newCount int32, err error)
	ResetFailedLoginCount(ctx context.Context, userID string) error
	SetUserLockedUntil(ctx context.Context, userID string, lockedUntilMs int64) error

	// Sessions (mode=session revocation). The verification middleware
	// looks the row up by SID on every authenticated request (via an
	// in-process cache; see internal/middleware/session.go). A non-zero
	// RevokedAtMs means the session has been killed and any access
	// token carrying this SID must be rejected.
	//
	// CreateSession is invoked from the token-issuance path when
	// `mode=session`. GetSessionBySid is the read on the hot path;
	// RevokeSession atomically marks one session revoked; and
	// RevokeSessionsForUser is invoked from DeleteRefreshTokensForUser
	// so the existing replay-detection path also kills the access
	// tokens.
	//
	// Implementations MUST guarantee SID uniqueness; CreateSession
	// returns ErrAlreadyExists when the SID collides. RevokeSession
	// is idempotent — re-revoking an already-revoked session is a
	// no-op rather than an error so concurrent revoke calls don't
	// race each other into failure.
	CreateSession(ctx context.Context, s *SessionRecord) (string, error)
	GetSessionBySid(ctx context.Context, sid string) (*SessionRecord, error)
	RevokeSession(ctx context.Context, sid string, atMs int64) error
	RevokeSessionsForUser(ctx context.Context, userID string, atMs int64) error

	// Refresh tokens
	FindRefreshTokenByHash(ctx context.Context, hash string) (*RefreshTokenRecord, error)
	// FindRefreshTokenByHashIncludingConsumed returns the row even when
	// consumed_at != 0, so the replay-detection branch can identify the
	// user_id whose sessions must be invalidated.
	FindRefreshTokenByHashIncludingConsumed(ctx context.Context, hash string) (*RefreshTokenRecord, error)
	CreateRefreshToken(ctx context.Context, r *RefreshTokenRecord) (string, error)
	DeleteRefreshToken(ctx context.Context, nodeID string) error
	DeleteRefreshTokensForUser(ctx context.Context, userID string) error
	// ConsumeRefreshTokenByHash marks the refresh-token row as rotated by
	// setting consumed_at = atMs. The row is NOT deleted: replay attempts
	// must still find it so they can be detected. The implementation must
	// only mark a row consumed if it is currently unconsumed (consumed_at
	// == 0); concurrent rotations of the same token must result in
	// exactly one caller succeeding. Returns ErrUnauthenticated when the
	// row is already consumed or does not exist.
	//
	// Background sweep: rows with consumed_at older than the desired
	// retention window (e.g. 90 days, comfortably exceeding the longest
	// refresh-token lifetime) may be hard-deleted by a periodic job. Such
	// a sweep is not implemented in this package.
	ConsumeRefreshTokenByHash(ctx context.Context, hash string, atMs int64) error

	// Passkey credentials
	ListPasskeyCredentials(ctx context.Context, userID string) ([]*PasskeyCredRecord, error)
	GetPasskeyCredentialByCredID(ctx context.Context, credentialID string) (*PasskeyCredRecord, error)
	CreatePasskeyCredential(ctx context.Context, r *PasskeyCredRecord) (string, error)
	UpdatePasskeyCredential(ctx context.Context, nodeID string, fields map[string]any) error

	// Passkey challenges
	GetPasskeyChallenge(ctx context.Context, nodeID string) (*PasskeyChallengeRecord, error)
	CreatePasskeyChallenge(ctx context.Context, r *PasskeyChallengeRecord) (string, error)
	DeletePasskeyChallenge(ctx context.Context, nodeID string) error

	// QR login sessions
	FindQrLoginSession(ctx context.Context, sessionID string) (*QrLoginSessionRecord, error)
	CreateQrLoginSession(ctx context.Context, r *QrLoginSessionRecord) (string, error)
	UpdateQrLoginSession(ctx context.Context, nodeID string, fields map[string]any) error
	// ConsumeQrLoginSession atomically transitions a QR login session
	// from status="approved" to status="consumed", setting updated_at
	// to atMs. Implementations must guarantee single-winner semantics
	// across concurrent callers: exactly one of N replicas polling the
	// same approved session may complete the transition; the rest see
	// ErrQrLoginNotPending and must NOT mint tokens. This is the
	// serialization point for the QR-login token-issuance flow in
	// multi-replica deployments. Returns ErrQrLoginNotPending when the
	// session does not exist or is no longer in the "approved" state.
	ConsumeQrLoginSession(ctx context.Context, nodeID string, atMs int64) error

	// OAuth one-time codes (hosted-flow SPA handover).
	//
	// CreateOAuthOneTimeCode stores the code-hash → user binding written
	// by the hosted /oauth/callback handler. ConsumeOAuthOneTimeCode is
	// the single-winner compare-and-set: it marks the row consumed
	// (consumed_at = atMs) only if it is currently unconsumed
	// (consumed_at == 0) AND not yet expired (expires_at > atMs),
	// returning the bound record on success. A replay, an expired code,
	// or a code that does not exist all return ErrOAuthCodeInvalid so
	// the redeem endpoint cannot be probed. Like ConsumeQrLoginSession
	// this is the serialization point across replicas: exactly one of N
	// concurrent redeems of the same code wins.
	CreateOAuthOneTimeCode(ctx context.Context, r *OAuthOneTimeCodeRecord) (string, error)
	ConsumeOAuthOneTimeCode(ctx context.Context, codeHash string, atMs int64) (*OAuthOneTimeCodeRecord, error)

	// Email login codes (OTP arm of passwordless email login).
	//
	// UpsertEmailLoginCode stores the latest code for an email, replacing
	// any existing live code for that address so at most one is valid at a
	// time (a re-request invalidates the previous code). It is keyed on
	// email; the unique index makes the upsert a delete-then-create or an
	// in-place overwrite depending on the backend.
	//
	// FindEmailLoginCodeByEmail returns the live row (consumed or not) so
	// the verify path can compare the code hash, count attempts, and
	// distinguish expired/consumed from a hash mismatch. Returns nil when
	// no row exists for the email.
	//
	// IncrementEmailLoginCodeAttempts bumps attempt_count by one. Used on
	// a wrong-code guess; the service invalidates the code once the count
	// reaches the cap captured on the record.
	//
	// ConsumeEmailLoginCode is the single-winner compare-and-set: it marks
	// the row consumed (consumed_at = atMs) only when currently unconsumed
	// AND unexpired, returning the bound record on success. A replay, an
	// expired code, or a missing code all return ErrEmailLoginCodeInvalid.
	UpsertEmailLoginCode(ctx context.Context, r *EmailLoginCodeRecord) (string, error)
	FindEmailLoginCodeByEmail(ctx context.Context, email string) (*EmailLoginCodeRecord, error)
	IncrementEmailLoginCodeAttempts(ctx context.Context, nodeID string) error
	ConsumeEmailLoginCode(ctx context.Context, email string, atMs int64) (*EmailLoginCodeRecord, error)

	// Magic-link tokens (magic-link arm of passwordless email login).
	//
	// CreateMagicLinkToken stores the token-hash → (email, return_to)
	// binding. ConsumeMagicLinkToken is the single-winner compare-and-set
	// (same shape as ConsumeOAuthOneTimeCode): it marks the row consumed
	// only when currently unconsumed AND unexpired, returning the bound
	// record on success. A replay, expired, or missing token all return
	// ErrMagicLinkInvalid.
	CreateMagicLinkToken(ctx context.Context, r *MagicLinkTokenRecord) (string, error)
	ConsumeMagicLinkToken(ctx context.Context, tokenHash string, atMs int64) (*MagicLinkTokenRecord, error)

	// Phone verification codes (SMS-OTP phone-ownership verification).
	//
	// UpsertPhoneVerificationCode stores the latest code for a user,
	// replacing any existing live code for that user so at most one is
	// valid at a time (a re-request invalidates the previous code). It is
	// keyed on user_id.
	//
	// FindPhoneVerificationCodeByUser returns the live row (consumed or
	// not) so the verify path can compare the code hash, count attempts,
	// and distinguish expired/consumed from a hash mismatch. Returns nil
	// when no row exists for the user.
	//
	// IncrementPhoneVerificationCodeAttempts bumps attempt_count by one,
	// used on a wrong-code guess; the service invalidates the code once
	// the count reaches the cap captured on the record.
	//
	// ConsumePhoneVerificationCode is the single-winner compare-and-set:
	// it marks the row consumed (consumed_at = atMs) only when currently
	// unconsumed AND unexpired, returning the bound record on success. A
	// replay, an expired code, or a missing code all return
	// ErrPhoneCodeInvalid.
	//
	// SetUserPhoneVerified records the verified phone on the user and
	// flips phone_verified, mirroring SetUserEmailVerified.
	UpsertPhoneVerificationCode(ctx context.Context, r *PhoneVerificationCodeRecord) (string, error)
	FindPhoneVerificationCodeByUser(ctx context.Context, userID string) (*PhoneVerificationCodeRecord, error)
	IncrementPhoneVerificationCodeAttempts(ctx context.Context, nodeID string) error
	ConsumePhoneVerificationCode(ctx context.Context, userID string, atMs int64) (*PhoneVerificationCodeRecord, error)
	SetUserPhoneVerified(ctx context.Context, userID, phoneNumber string, atMs int64) error

	// TOTP credentials
	GetTotpCredential(ctx context.Context, userID string) (*TotpCredRecord, error)
	CreateTotpCredential(ctx context.Context, r *TotpCredRecord) (string, error)
	UpdateTotpCredential(ctx context.Context, nodeID string, fields map[string]any) error
	DeleteTotpCredential(ctx context.Context, nodeID string) error
	DeleteTotpCredentialsForUser(ctx context.Context, userID string) error

	// Recovery codes
	CreateRecoveryCode(ctx context.Context, r *RecoveryCodeRecord) (string, error)
	FindRecoveryCodeByHash(ctx context.Context, userID, hash string) (*RecoveryCodeRecord, error)
	UpdateRecoveryCode(ctx context.Context, nodeID string, fields map[string]any) error
	DeleteRecoveryCodesForUser(ctx context.Context, userID string) error

	// Login challenges (TOTP 2FA step)
	CreateLoginChallenge(ctx context.Context, r *LoginChallengeRecord) (string, error)
	GetLoginChallengeByChallengeID(ctx context.Context, challengeID string) (*LoginChallengeRecord, error)
	DeleteLoginChallenge(ctx context.Context, nodeID string) error

	// User invitations
	FindInvitationByHash(ctx context.Context, tokenHash string) (*InvitationRecord, error)
	UpdateInvitation(ctx context.Context, nodeID string, fields map[string]any) error

	// Password-reset tokens
	CreatePasswordResetToken(ctx context.Context, t *PasswordResetToken) error
	FindPasswordResetTokenByHash(ctx context.Context, tokenHash string) (*PasswordResetToken, error)
	MarkPasswordResetTokenConsumed(ctx context.Context, tokenID string, atMs int64) error

	// Email-verification tokens
	CreateEmailVerificationToken(ctx context.Context, t *EmailVerificationToken) error
	FindEmailVerificationTokenByHash(ctx context.Context, tokenHash string) (*EmailVerificationToken, error)
	MarkEmailVerificationTokenConsumed(ctx context.Context, tokenID string, atMs int64) error

	// User email-verified update
	SetUserEmailVerified(ctx context.Context, userID string, atMs int64) error

	// User idv-verified update; called by IdentityVerificationService
	// when a verification reaches APPROVED.
	SetUserIDVVerified(ctx context.Context, userID string, atMs int64) error

	// Identity-verification records (document + selfie verification).
	// Latest ordering is by CreatedAt descending.
	CreateIdentityVerification(ctx context.Context, r *IdentityVerificationRecord) error
	GetIdentityVerification(ctx context.Context, verificationID string) (*IdentityVerificationRecord, error)
	GetLatestIdentityVerificationForUser(ctx context.Context, userID string) (*IdentityVerificationRecord, error)
	UpdateIdentityVerificationStatus(ctx context.Context, verificationID, status, rejectionReason string, completedAtMs, updatedAtMs int64) error

	// Email-change tokens (primary email rotation, double-opt-in)
	CreateEmailChangeToken(ctx context.Context, t *EmailChangeToken) error
	FindEmailChangeTokenByHash(ctx context.Context, tokenHash string) (*EmailChangeToken, error)
	MarkEmailChangeTokenConsumed(ctx context.Context, tokenID string, atMs int64) error
	// UpdateUserEmail sets the user's primary email and marks it verified
	// (since the new address has just proven control via the consumed
	// token). Implementations must also set updated_at = atMs.
	UpdateUserEmail(ctx context.Context, userID, newEmail string, atMs int64) error

	// OAuth identities — links a (provider, provider_user_id) pair to a
	// local User so OAuth login can survive provider-side email changes.
	FindUserByProviderID(ctx context.Context, provider, providerUserID string) (*User, error)
	CreateOAuthIdentity(ctx context.Context, oi *OAuthIdentity) error
	ListOAuthIdentitiesForUser(ctx context.Context, userID string) ([]*OAuthIdentity, error)

	// Garbage-collection sweepers for ephemeral state. The
	// background sweeper started by app.New calls these every
	// GATEWAY_SWEEPER_INTERVAL_SECONDS; each call deletes up to
	// `limit` rows whose ExpiresAt is strictly less than `beforeMs`.
	// Every shipping backend (memory, postgres, entdb) implements the
	// real sweep; the ErrSweepNotImplemented sentinel remains so a new
	// backend can land its CRUD methods first and its sweep in a
	// follow-up PR without erroring the sweeper goroutine.
	// Implementations MUST reject limit <= 0 — an unbounded delete
	// batch could lock a hot table for an unbounded window.
	//
	// Return value is only error: tenant-shard-db v1.14.0's
	// OpDeleteWhere primitive intentionally does not return a deleted-
	// row count (see #540 "applied, no count for v1"), so identity
	// drops the count from the contract to avoid forcing one of the
	// three backends into a per-row tally that the others can't
	// match. The app-layer sweeper emits per-tick "sweep completed"
	// events instead of a row count.
	DeleteExpiredWebAuthnChallenges(ctx context.Context, beforeMs int64, limit int) error
	DeleteExpiredEmailVerificationTokens(ctx context.Context, beforeMs int64, limit int) error
	DeleteExpiredPasswordResetTokens(ctx context.Context, beforeMs int64, limit int) error
	DeleteExpiredEmailChangeTokens(ctx context.Context, beforeMs int64, limit int) error
	DeleteExpiredLoginChallenges(ctx context.Context, beforeMs int64, limit int) error
	DeleteExpiredOAuthOneTimeCodes(ctx context.Context, beforeMs int64, limit int) error
	DeleteExpiredEmailLoginCodes(ctx context.Context, beforeMs int64, limit int) error
	DeleteExpiredMagicLinkTokens(ctx context.Context, beforeMs int64, limit int) error
	DeleteExpiredPhoneVerificationCodes(ctx context.Context, beforeMs int64, limit int) error
	DeleteExpiredQrLoginSessions(ctx context.Context, beforeMs int64, limit int) error
	DeleteExpiredInvitations(ctx context.Context, beforeMs int64, limit int) error
}

Repository abstracts all persistence operations for the auth service.

type ResetPasswordResult

type ResetPasswordResult struct {
	TemporaryPassword string
	ResetToken        string
}

ResetPasswordResult is returned by AdminService.ResetUserPassword.

type ResolvedProject added in v0.16.0

type ResolvedProject struct {
	ID                 string
	StorageScopeID     string
	PrimaryAuthDomain  string
	CORSAllowedOrigins []string
}

ResolvedProject is what a ProjectResolver returns: the minimal project identity the middleware needs to build a ProjectScope. It is a driver-agnostic value so the resolver contract does not leak a concrete store type into the middleware or app wiring.

type ReturnAllowlist added in v0.11.0

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

ReturnAllowlist is the fail-closed validator for an app return_to URL. It is built from GATEWAY_OAUTH_ALLOWED_RETURN_URLS — a comma-separated list of exact origins or URL prefixes — and shared by the hosted OAuth flow (where the HTTP handler checks return_to at /oauth/start) and the passwordless magic-link flow (where RequestMagicLink checks the requested return_to). A return_to is allowed when it equals an entry or begins with one; everything else is rejected.

An empty allowlist disables both flows that depend on it: Enabled() reports false and Allows() rejects everything.

func ParseReturnAllowlist added in v0.11.0

func ParseReturnAllowlist(csv string) ReturnAllowlist

ParseReturnAllowlist splits the comma-separated config value into trimmed, non-empty entries. Whitespace-only entries are dropped.

func (ReturnAllowlist) Allows added in v0.11.0

func (a ReturnAllowlist) Allows(returnTo string) bool

Allows reports whether returnTo is permitted. A returnTo matches when it equals an allowlist entry or begins with one (prefix match), so a deployer can allow an entire app origin or pin a specific callback path. The match is exact-byte; no normalization is applied because a normalized-but-mismatched URL is exactly the open-redirect case the allowlist exists to close.

func (ReturnAllowlist) Enabled added in v0.11.0

func (a ReturnAllowlist) Enabled() bool

Enabled reports whether any allowlist entry is configured.

func (ReturnAllowlist) Entries added in v0.11.0

func (a ReturnAllowlist) Entries() []string

Entries returns the configured allowlist entries (for startup logging).

type Session

type Session struct {
	ID         string
	DeviceName string
	IPAddress  string
	UserAgent  string
	CreatedAt  int64
	LastUsedAt int64
	ExpiresAt  int64
	Current    bool
}

Session represents an active refresh-token-backed session.

type SessionRecord added in v0.8.0

type SessionRecord struct {
	NodeID      string
	SID         string
	UserID      string
	CreatedAtMs int64 // epoch ms
	RevokedAtMs int64 // epoch ms; 0 = active
}

SessionRecord represents a stored access-token-bound session, used by `GATEWAY_REVOCATION_MODE=session` deployments. The SID is the stable per-session identifier carried as the `sid` claim on access tokens; the verification middleware looks the row up on every authenticated request (via an in-process cache).

In `mode=ttl` deployments (the default) this record type is not written or read — the row type costs zero on the hot path for deployers who never opt in.

type StubDB

type StubDB struct{}

StubDB implements DB (and audit.NodeWriter) but returns ErrServiceUnavailable for every method.

func (StubDB) ExecuteAtomic

func (StubDB) GetEdgesFrom

func (StubDB) GetEdgesFrom(context.Context, string, string, string, int) ([]*entdb.Edge, error)

func (StubDB) GetEdgesTo

func (StubDB) GetEdgesTo(context.Context, string, string, string, int) ([]*entdb.Edge, error)

func (StubDB) GetNode

func (StubDB) QueryNodes

func (StubDB) QueryNodes(context.Context, string, string, int, map[string]any) ([]*entdb.Node, error)

func (StubDB) RegisterUserInTenant added in v0.7.1

func (StubDB) RegisterUserInTenant(context.Context, string, string, string, string, string) error

func (StubDB) SearchNodes

func (StubDB) SearchNodes(context.Context, string, string, int, string) ([]*entdb.Node, error)

type StubRepository

type StubRepository struct{}

StubRepository implements Repository but returns ErrServiceUnavailable for every method. Use it as a placeholder until the EntDB-backed repository is implemented.

func (StubRepository) ConsumeEmailLoginCode added in v0.11.0

func (StubRepository) ConsumeEmailLoginCode(context.Context, string, int64) (*EmailLoginCodeRecord, error)

func (StubRepository) ConsumeMagicLinkToken added in v0.11.0

func (StubRepository) ConsumeMagicLinkToken(context.Context, string, int64) (*MagicLinkTokenRecord, error)

func (StubRepository) ConsumeOAuthOneTimeCode added in v0.9.0

func (StubRepository) ConsumeOAuthOneTimeCode(context.Context, string, int64) (*OAuthOneTimeCodeRecord, error)

func (StubRepository) ConsumePhoneVerificationCode added in v0.14.0

func (StubRepository) ConsumePhoneVerificationCode(context.Context, string, int64) (*PhoneVerificationCodeRecord, error)

func (StubRepository) ConsumeQrLoginSession added in v0.7.2

func (StubRepository) ConsumeQrLoginSession(context.Context, string, int64) error

func (StubRepository) ConsumeRefreshTokenByHash

func (StubRepository) ConsumeRefreshTokenByHash(context.Context, string, int64) error

func (StubRepository) CreateEmailChangeToken

func (StubRepository) CreateEmailChangeToken(context.Context, *EmailChangeToken) error

func (StubRepository) CreateEmailVerificationToken

func (StubRepository) CreateEmailVerificationToken(context.Context, *EmailVerificationToken) error

func (StubRepository) CreateIdentityVerification added in v0.4.0

func (StubRepository) CreateIdentityVerification(context.Context, *IdentityVerificationRecord) error

func (StubRepository) CreateLoginChallenge

func (StubRepository) CreateLoginChallenge(context.Context, *LoginChallengeRecord) (string, error)

func (StubRepository) CreateMagicLinkToken added in v0.11.0

func (StubRepository) CreateMagicLinkToken(context.Context, *MagicLinkTokenRecord) (string, error)

func (StubRepository) CreateOAuthIdentity

func (StubRepository) CreateOAuthIdentity(context.Context, *OAuthIdentity) error

func (StubRepository) CreateOAuthOneTimeCode added in v0.9.0

func (StubRepository) CreateOAuthOneTimeCode(context.Context, *OAuthOneTimeCodeRecord) (string, error)

func (StubRepository) CreatePasskeyChallenge

func (StubRepository) CreatePasskeyChallenge(context.Context, *PasskeyChallengeRecord) (string, error)

func (StubRepository) CreatePasskeyCredential

func (StubRepository) CreatePasskeyCredential(context.Context, *PasskeyCredRecord) (string, error)

func (StubRepository) CreatePasswordResetToken

func (StubRepository) CreatePasswordResetToken(context.Context, *PasswordResetToken) error

func (StubRepository) CreateQrLoginSession

func (StubRepository) CreateQrLoginSession(context.Context, *QrLoginSessionRecord) (string, error)

func (StubRepository) CreateRecoveryCode

func (StubRepository) CreateRecoveryCode(context.Context, *RecoveryCodeRecord) (string, error)

func (StubRepository) CreateRefreshToken

func (StubRepository) CreateRefreshToken(context.Context, *RefreshTokenRecord) (string, error)

func (StubRepository) CreateSession added in v0.8.0

func (StubRepository) CreateTotpCredential

func (StubRepository) CreateTotpCredential(context.Context, *TotpCredRecord) (string, error)

func (StubRepository) CreateUser

func (StubRepository) CreateUser(context.Context, *User) (string, error)

func (StubRepository) DeleteExpiredEmailChangeTokens added in v0.7.1

func (StubRepository) DeleteExpiredEmailChangeTokens(context.Context, int64, int) error

func (StubRepository) DeleteExpiredEmailLoginCodes added in v0.11.0

func (StubRepository) DeleteExpiredEmailLoginCodes(context.Context, int64, int) error

func (StubRepository) DeleteExpiredEmailVerificationTokens added in v0.7.1

func (StubRepository) DeleteExpiredEmailVerificationTokens(context.Context, int64, int) error

func (StubRepository) DeleteExpiredInvitations added in v0.15.0

func (StubRepository) DeleteExpiredInvitations(context.Context, int64, int) error

func (StubRepository) DeleteExpiredLoginChallenges added in v0.7.1

func (StubRepository) DeleteExpiredLoginChallenges(context.Context, int64, int) error

func (StubRepository) DeleteExpiredMagicLinkTokens added in v0.11.0

func (StubRepository) DeleteExpiredMagicLinkTokens(context.Context, int64, int) error

func (StubRepository) DeleteExpiredOAuthOneTimeCodes added in v0.9.0

func (StubRepository) DeleteExpiredOAuthOneTimeCodes(context.Context, int64, int) error

func (StubRepository) DeleteExpiredPasswordResetTokens added in v0.7.1

func (StubRepository) DeleteExpiredPasswordResetTokens(context.Context, int64, int) error

func (StubRepository) DeleteExpiredPhoneVerificationCodes added in v0.14.0

func (StubRepository) DeleteExpiredPhoneVerificationCodes(context.Context, int64, int) error

func (StubRepository) DeleteExpiredQrLoginSessions added in v0.15.0

func (StubRepository) DeleteExpiredQrLoginSessions(context.Context, int64, int) error

func (StubRepository) DeleteExpiredWebAuthnChallenges added in v0.7.1

func (StubRepository) DeleteExpiredWebAuthnChallenges(context.Context, int64, int) error

func (StubRepository) DeleteLoginChallenge

func (StubRepository) DeleteLoginChallenge(context.Context, string) error

func (StubRepository) DeletePasskeyChallenge

func (StubRepository) DeletePasskeyChallenge(context.Context, string) error

func (StubRepository) DeleteRecoveryCodesForUser

func (StubRepository) DeleteRecoveryCodesForUser(context.Context, string) error

func (StubRepository) DeleteRefreshToken

func (StubRepository) DeleteRefreshToken(context.Context, string) error

func (StubRepository) DeleteRefreshTokensForUser

func (StubRepository) DeleteRefreshTokensForUser(context.Context, string) error

func (StubRepository) DeleteTotpCredential

func (StubRepository) DeleteTotpCredential(context.Context, string) error

func (StubRepository) DeleteTotpCredentialsForUser

func (StubRepository) DeleteTotpCredentialsForUser(context.Context, string) error

func (StubRepository) DeleteUser added in v0.13.0

func (StubRepository) DeleteUser(context.Context, string) error

func (StubRepository) FindEmailChangeTokenByHash

func (StubRepository) FindEmailChangeTokenByHash(context.Context, string) (*EmailChangeToken, error)

func (StubRepository) FindEmailLoginCodeByEmail added in v0.11.0

func (StubRepository) FindEmailLoginCodeByEmail(context.Context, string) (*EmailLoginCodeRecord, error)

func (StubRepository) FindEmailVerificationTokenByHash

func (StubRepository) FindEmailVerificationTokenByHash(context.Context, string) (*EmailVerificationToken, error)

func (StubRepository) FindInvitationByHash

func (StubRepository) FindInvitationByHash(context.Context, string) (*InvitationRecord, error)

func (StubRepository) FindPasswordResetTokenByHash

func (StubRepository) FindPasswordResetTokenByHash(context.Context, string) (*PasswordResetToken, error)

func (StubRepository) FindPhoneVerificationCodeByUser added in v0.14.0

func (StubRepository) FindPhoneVerificationCodeByUser(context.Context, string) (*PhoneVerificationCodeRecord, error)

func (StubRepository) FindQrLoginSession

func (StubRepository) FindRecoveryCodeByHash

func (StubRepository) FindRecoveryCodeByHash(context.Context, string, string) (*RecoveryCodeRecord, error)

func (StubRepository) FindRefreshTokenByHash

func (StubRepository) FindRefreshTokenByHash(context.Context, string) (*RefreshTokenRecord, error)

func (StubRepository) FindRefreshTokenByHashIncludingConsumed

func (StubRepository) FindRefreshTokenByHashIncludingConsumed(context.Context, string) (*RefreshTokenRecord, error)

func (StubRepository) FindUserByEmail

func (StubRepository) FindUserByEmail(context.Context, string) (*User, error)

func (StubRepository) FindUserByProviderID

func (StubRepository) FindUserByProviderID(context.Context, string, string) (*User, error)

func (StubRepository) GetIdentityVerification added in v0.4.0

func (StubRepository) GetIdentityVerification(context.Context, string) (*IdentityVerificationRecord, error)

func (StubRepository) GetLatestIdentityVerificationForUser added in v0.4.0

func (StubRepository) GetLatestIdentityVerificationForUser(context.Context, string) (*IdentityVerificationRecord, error)

func (StubRepository) GetLoginChallengeByChallengeID

func (StubRepository) GetLoginChallengeByChallengeID(context.Context, string) (*LoginChallengeRecord, error)

func (StubRepository) GetPasskeyChallenge

func (StubRepository) GetPasskeyCredentialByCredID

func (StubRepository) GetPasskeyCredentialByCredID(context.Context, string) (*PasskeyCredRecord, error)

func (StubRepository) GetSessionBySid added in v0.8.0

func (StubRepository) GetSessionBySid(context.Context, string) (*SessionRecord, error)

func (StubRepository) GetTotpCredential

func (StubRepository) GetTotpCredential(context.Context, string) (*TotpCredRecord, error)

func (StubRepository) GetUser

func (StubRepository) IncrementEmailLoginCodeAttempts added in v0.11.0

func (StubRepository) IncrementEmailLoginCodeAttempts(context.Context, string) error

func (StubRepository) IncrementFailedLoginCount

func (StubRepository) IncrementFailedLoginCount(context.Context, string) (int32, error)

func (StubRepository) IncrementPhoneVerificationCodeAttempts added in v0.14.0

func (StubRepository) IncrementPhoneVerificationCodeAttempts(context.Context, string) error

func (StubRepository) ListOAuthIdentitiesForUser

func (StubRepository) ListOAuthIdentitiesForUser(context.Context, string) ([]*OAuthIdentity, error)

func (StubRepository) ListPasskeyCredentials

func (StubRepository) ListPasskeyCredentials(context.Context, string) ([]*PasskeyCredRecord, error)

func (StubRepository) MarkEmailChangeTokenConsumed

func (StubRepository) MarkEmailChangeTokenConsumed(context.Context, string, int64) error

func (StubRepository) MarkEmailVerificationTokenConsumed

func (StubRepository) MarkEmailVerificationTokenConsumed(context.Context, string, int64) error

func (StubRepository) MarkPasswordResetTokenConsumed

func (StubRepository) MarkPasswordResetTokenConsumed(context.Context, string, int64) error

func (StubRepository) ResetFailedLoginCount

func (StubRepository) ResetFailedLoginCount(context.Context, string) error

func (StubRepository) RevokeSession added in v0.8.0

func (StubRepository) RevokeSession(context.Context, string, int64) error

func (StubRepository) RevokeSessionsForUser added in v0.8.0

func (StubRepository) RevokeSessionsForUser(context.Context, string, int64) error

func (StubRepository) SetUserEmailVerified

func (StubRepository) SetUserEmailVerified(context.Context, string, int64) error

func (StubRepository) SetUserIDVVerified added in v0.4.2

func (StubRepository) SetUserIDVVerified(context.Context, string, int64) error

func (StubRepository) SetUserLockedUntil

func (StubRepository) SetUserLockedUntil(context.Context, string, int64) error

func (StubRepository) SetUserPhoneVerified added in v0.14.0

func (StubRepository) SetUserPhoneVerified(context.Context, string, string, int64) error

func (StubRepository) UpdateIdentityVerificationStatus added in v0.4.0

func (StubRepository) UpdateIdentityVerificationStatus(context.Context, string, string, string, int64, int64) error

func (StubRepository) UpdateInvitation

func (StubRepository) UpdateInvitation(context.Context, string, map[string]any) error

func (StubRepository) UpdatePasskeyCredential

func (StubRepository) UpdatePasskeyCredential(context.Context, string, map[string]any) error

func (StubRepository) UpdateQrLoginSession

func (StubRepository) UpdateQrLoginSession(context.Context, string, map[string]any) error

func (StubRepository) UpdateRecoveryCode

func (StubRepository) UpdateRecoveryCode(context.Context, string, map[string]any) error

func (StubRepository) UpdateTotpCredential

func (StubRepository) UpdateTotpCredential(context.Context, string, map[string]any) error

func (StubRepository) UpdateUser

func (StubRepository) UpdateUser(context.Context, string, map[string]any) error

func (StubRepository) UpdateUserEmail

func (StubRepository) UpdateUserEmail(context.Context, string, string, int64) error

func (StubRepository) UpsertEmailLoginCode added in v0.11.0

func (StubRepository) UpsertEmailLoginCode(context.Context, *EmailLoginCodeRecord) (string, error)

func (StubRepository) UpsertPhoneVerificationCode added in v0.14.0

func (StubRepository) UpsertPhoneVerificationCode(context.Context, *PhoneVerificationCodeRecord) (string, error)

type Tenant added in v0.17.0

type Tenant struct {
	ID            string
	ProjectID     string
	Name          string
	PrimaryDomain string
	Status        string
	CreatedAtMs   int64
	UpdatedAtMs   int64
}

Tenant is a company-governance entity within a Project, auto-formed per verified non-public email domain. It owns email Domains, a LoginPolicy, and tenant memberships. A Tenant is `latent` until one of its domains is verified, at which point it becomes `claimed` and authoritative.

type TenantAutoFormStore added in v0.17.0

type TenantAutoFormStore interface {
	// EnsureTenantForDomain idempotently ensures a latent Tenant and its
	// email Domain exist for (projectID, domain), then records a
	// domain-derived membership for userID. One tenant per email domain
	// within a project is enforced transactionally — the tenant and domain
	// are created in a single transaction, so a lost race against a
	// concurrent signer rolls BOTH back (no orphan tenant) and the winner's
	// tenant is used. Returns the resolved tenant id.
	//
	// The caller must only invoke this for a non-public domain (see
	// config.IsPublicEmailDomain); the store does not re-check.
	EnsureTenantForDomain(ctx context.Context, projectID, domain, userID string) (string, error)
}

TenantAutoFormStore is the transactional auto-formation primitive: it turns "a user signed up with a company email domain" into the governance rows that represent it, atomically and idempotently.

type TenantInvitation added in v0.17.0

type TenantInvitation struct {
	ID        string
	ProjectID string
	TenantID  string
	TokenHash string
	Email     string
	// InvitedBy is provenance only — intentionally not an FK, so deleting
	// the inviter neither blocks the delete nor rewrites the audit trail.
	InvitedBy    string
	Role         string
	Status       string
	ExpiresAtMs  int64
	AcceptedAtMs int64
	CreatedAtMs  int64
}

TenantInvitation is a pending offer to join a Tenant, addressed to an email and redeemed by a hashed token. At most one open (pending) invitation per (project, tenant, email).

type TenantMembership added in v0.17.0

type TenantMembership struct {
	ID          string
	ProjectID   string
	TenantID    string
	UserID      string
	Source      string
	Role        string
	Status      string
	CreatedAtMs int64
	UpdatedAtMs int64
}

TenantMembership is a user's materialized relationship to a Tenant within a Project. At most one row per (project, tenant, user).

type TenantStore added in v0.17.0

type TenantStore interface {
	// CreateTenant inserts a tenant. ProjectID is required; a blank id is
	// generated and written back. The assigned id is returned.
	CreateTenant(ctx context.Context, t *Tenant) (string, error)
	// GetTenant returns the tenant by id within a project, or (nil, nil).
	GetTenant(ctx context.Context, projectID, tenantID string) (*Tenant, error)
	// GetTenantByPrimaryDomain returns the tenant whose primary_domain
	// equals domain (case-insensitive) within a project, or (nil, nil).
	GetTenantByPrimaryDomain(ctx context.Context, projectID, domain string) (*Tenant, error)
	// SetTenantStatus transitions a tenant's status (e.g. latent→claimed)
	// and stamps updated_at. Unknown ids are a no-op.
	SetTenantStatus(ctx context.Context, projectID, tenantID, status string) error
	// ListTenants returns every tenant in a project, newest first.
	ListTenants(ctx context.Context, projectID string) ([]*Tenant, error)
}

TenantStore persists Tenants within a Project. Reads that miss return (nil, nil), never an error; only infrastructure failures error.

type TotpCredRecord

type TotpCredRecord struct {
	NodeID          string
	UserID          string
	SecretEncrypted string
	Verified        bool
	CreatedAt       int64
	LastUsedAt      int64
}

TotpCredRecord represents a stored TOTP credential.

type User

type User struct {
	ID               string
	Email            string
	Name             string
	AvatarURL        string
	Role             string
	CreatedAt        time.Time
	UpdatedAt        time.Time
	TotpRequired     bool
	Status           string // "active", "invited", "deactivated", "suspended"
	RecoveryEmail    string
	QuotaBytes       int64
	LastLoginAtMs    int64
	PasswordHash     string // never exposed via RPC
	FailedLoginCount int
	LockedUntil      int64 // epoch ms; 0 = not locked
	EmailVerified    bool
	EmailVerifiedAt  int64 // epoch ms
	IDVVerified      bool  // latest identity verification reached APPROVED
	IDVVerifiedAt    int64 // epoch ms; 0 = never verified
	PhoneNumber      string
	PhoneVerified    bool
	PhoneVerifiedAt  int64 // epoch ms; 0 = never verified
}

User represents a user in the identity system.

type UserDirectory added in v0.18.0

type UserDirectory interface {
	GetUser(ctx context.Context, userID string) (*User, error)
}

UserDirectory is the narrow user-lookup boundary MembershipService needs: resolving the accepting caller so its account email can be matched against the invitation. service.Repository satisfies it; injecting only this method keeps the service decoupled from the full repository surface and trivially fakeable. The redesign's governance plane is a single postgres control database, so the boot-time repository is the correct (and only) handle — there is no per-tenant repo sharding in redesign mode.

Jump to

Keyboard shortcuts

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