postgres

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jun 7, 2026 License: MIT Imports: 24 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type PGRoleRepo

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

PGRoleRepo is the cell-private PostgreSQL implementation of ports.RoleRepository. It reads/writes the `roles` and `role_assignments` tables (migration 019).

func NewPGRoleRepo

func NewPGRoleRepo(
	pool *pgxpool.Pool,
	txRunner persistence.TxRunner,
	clk clock.Clock,
) (*PGRoleRepo, error)

NewPGRoleRepo constructs a PGRoleRepo. Fails fast on nil dependencies.

func (*PGRoleRepo) AssignToUser

func (r *PGRoleRepo) AssignToUser(ctx context.Context, t tenant.TenantID, userID, roleID string) (bool, error)

AssignToUser assigns a role to a user within a tenant. Idempotent: returns changed=false when the assignment already existed. Returns ErrAuthRoleNotFound when the role does not exist (FK on role_id). Returns ErrAuthUserNotFound when the user does not exist (FK on user_id). Fallback for unknown FK violations returns ErrAuthRoleNotFound.

func (*PGRoleRepo) CountByRole

func (r *PGRoleRepo) CountByRole(ctx context.Context, t tenant.TenantID, roleID string) (int, error)

CountByRole returns the total count of role_assignments for roleID regardless of user status. Used for bootstrap idempotency (adminprovision); MUST NOT be used as the last-admin invariant counter — see CountEffectiveAdmins.

func (*PGRoleRepo) CountEffectiveAdmins

func (r *PGRoleRepo) CountEffectiveAdmins(ctx context.Context, t tenant.TenantID) (int, error)

CountEffectiveAdmins returns the number of users that are simultaneously status='active' AND hold the admin role. Satisfies the domain. EffectiveAdminCounter sealed interface (S4.0 invariant counter).

Acquires advisory xact lock 'gocell.accesscore.last_admin' inside the CTE so concurrent guard paths (this read, removeIfNotLastSQL CTE, and the migration-024 trigger on users) serialize.

CONTRACT (runtime-enforced): Must be called within an open write transaction. The advisory lock is transaction-scoped (pg_advisory_xact_lock) and releases on commit/rollback; outside-transaction callers would acquire and immediately release the lock, defeating the invariant guarantee. The function fail-fasts with ErrInternal when no pgx.Tx is present under kernel/persistence.TxCtxKey — same shape as PGSetupLock.Acquire / OutboxWriter.Write. If a lock-free read is ever needed for diagnostics or observability, add a dedicated variant without the advisory-lock CTE rather than relaxing this contract.

func (*PGRoleRepo) Create

func (r *PGRoleRepo) Create(ctx context.Context, t tenant.TenantID, role *domain.Role) error

Create upserts a role (seed/bootstrap semantics: existing role is overwritten). The SQL uses ON CONFLICT (tenant_id, id) DO UPDATE — the composite PK on `roles`. A unique-violation (SQLSTATE 23505) is therefore structurally impossible here; any error is a genuine infra failure, classified as ErrInternal.

func (*PGRoleRepo) EffectiveAdminExists

func (r *PGRoleRepo) EffectiveAdminExists(ctx context.Context, t tenant.TenantID) (bool, error)

EffectiveAdminExists implements ports.RoleRepository — see the port godoc for fast-path semantics. Pool-driven (no tx required, no advisory lock).

func (*PGRoleRepo) GetByID

func (r *PGRoleRepo) GetByID(ctx context.Context, t tenant.TenantID, id string) (*domain.Role, error)

GetByID fetches a role by composite (tenant_id, id) key. Returns ErrAuthRoleNotFound when absent.

func (*PGRoleRepo) GetByUserID

func (r *PGRoleRepo) GetByUserID(ctx context.Context, t tenant.TenantID, userID string) ([]*domain.Role, error)

GetByUserID returns all roles assigned to the user in the given tenant. Returns an empty slice when the user has no roles (mirrors mem behavior).

func (*PGRoleRepo) ListByUserID

func (r *PGRoleRepo) ListByUserID(ctx context.Context, t tenant.TenantID, userID string, params query.ListParams) ([]*domain.Role, error)

ListByUserID returns a paginated, sorted list of roles assigned to userID. Mirrors the mem implementation: loads all roles for the user, then applies query.Sort and query.ApplyCursor in Go.

func (*PGRoleRepo) RemoveFromUser

func (r *PGRoleRepo) RemoveFromUser(ctx context.Context, t tenant.TenantID, userID, roleID string) error

RemoveFromUser removes a role assignment. Idempotent — no error when the assignment did not exist.

SAFETY: callers needing the at-least-one-effective-admin invariant (revoke admin role, demote sole admin) must use RemoveFromUserIfNotLast, which combines the application-layer CTE check with the migration-024 trigger safety net. RemoveFromUser issues a plain DELETE and relies SOLELY on the trigger to enforce the invariant — when the trigger blocks the DELETE, isLastAdminProtected translates SQLSTATE P0001 into ErrAuthLastAdminProtected (HTTP 403). The single legitimate caller passing roleID == auth.RoleAdmin is adminprovision.Compensate, which runs after a setup failure: if the just-provisioned admin is the only effective admin, the trigger correctly blocks the cleanup and leaves the operator with a usable account rather than an unusable system.

func (*PGRoleRepo) RemoveFromUserIfNotLast

func (r *PGRoleRepo) RemoveFromUserIfNotLast(ctx context.Context, t tenant.TenantID, userID, roleID string) (bool, error)

RemoveFromUserIfNotLast removes a role assignment with admin-scoped last-effective-admin protection (ADR-admin-invariant §3.2, S4.0). For roleID == auth.RoleAdmin the CTE acquires an advisory xact lock plus FOR UPDATE OF u on the other active-admin users, atomically serializing concurrent revocations / locks / deletes. The DELETE only fires when at least one OTHER effective admin (status='active' AND admin) remains. For any other roleID the operation is a plain idempotent DELETE (matches the migration-024 trigger scope: `IF OLD.role_id <> 'admin' THEN RETURN OLD;`).

CONTRACT (runtime-enforced for admin path): the admin-role branch must be called within an open write transaction so the CTE's pg_advisory_xact_lock scopes to the caller's tx, not a one-shot pool connection. The function fail-fasts with ErrInternal when roleID equals auth.RoleAdmin and no pgx.Tx is present under kernel/persistence.TxCtxKey. The non-admin path stays pool-driven (no lock involved); calling it outside a tx is fine.

Returns:

  • (true, nil) — role was held and successfully removed.
  • (false, nil) — role was not held (idempotent no-op).
  • (false, ErrAuthLastAdminProtected) — admin path only; removal would leave zero effective admins. Both the app-level CTE detect path and the DB trigger safety-net path return the same errcode so client handlers match a single business invariant.

type PGSetupLock

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

PGSetupLock serializes the admin provisioning path across concurrent processes via PostgreSQL advisory locks. It uses pg_advisory_xact_lock so the lock is automatically released at transaction commit or rollback — no explicit Release is required or possible.

Multi-pod deployments that both hit POST /setup/admin before any admin exists now block on this lock inside the same transaction that writes the admin user and emits the outbox event. Only the first committer persists; the second reads zero-count at fast-path and returns OutcomeAlreadyExists / 410 Gone.

The advisory lock key is derived from the well-known sentinel string "accesscore.admin.setup" via hashtextextended(..., 0). This is stable across all PG versions and does not collide with table OIDs.

func NewPGSetupLock

func NewPGSetupLock(txRunner persistence.TxRunner) (*PGSetupLock, error)

NewPGSetupLock constructs a PGSetupLock. Returns an error when txRunner is nil so composition roots fail at construction time rather than at the first Acquire.

func (*PGSetupLock) Acquire

func (l *PGSetupLock) Acquire(ctx context.Context) error

Acquire blocks until the advisory lock is granted within the ambient transaction. ctx must carry a live pgx.Tx injected by txRunner.RunInTx (stored under kernel/persistence.TxCtxKey). If no transaction is present, Acquire returns ErrInternal because the xact-scoped lock semantics require an enclosing transaction.

type PGUserRepo

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

PGUserRepo is the cell-private PostgreSQL implementation of ports.UserRepository. It reads/writes the `users` table (migration 017).

Transaction contract — dual-signal pattern (S3+S5 PR #449 round-3 clarification). The txRunner field is a *construction-time policy declaration*: it fail-fasts at NewPGUserRepo when the L2 caller has not wired a real TxRunner (single source of truth for "this repo is intended for L2-atomic call sites"). The repo methods themselves do NOT invoke txRunner.RunInTx directly because all current write paths are single-statement (Create / Update / Delete). Instead, methods extract any ambient pgx.Tx from ctx via kernel/persistence.TxCtxKey (the value stored by adapters/postgres.TxManager.RunInTx); when no tx is in ctx the methods fall through to the pool. The setup service wraps Create + outbox.Write in a single TxManager.RunInTx call so both writes share the tx that the package-local typed executor picks up here.

Compare runtime/auth/refresh adapters/postgres/refresh_store.go where txRunner IS invoked directly because its multi-statement methods (Peek, Rotate) need an explicit boundary. PGUserRepo's pattern is the "single-statement repo" variant of the same dual-signal contract.

func NewPGUserRepo

func NewPGUserRepo(
	pool *pgxpool.Pool,
	txRunner persistence.TxRunner,
	clk clock.Clock,
) (*PGUserRepo, error)

NewPGUserRepo constructs a PGUserRepo. Fails fast on nil dependencies.

func (*PGUserRepo) BumpAuthzEpoch

func (r *PGUserRepo) BumpAuthzEpoch(ctx context.Context, t tenant.TenantID, userID string, tok credentialfence.FenceToken) (int64, error)

BumpAuthzEpoch atomically increments users.authz_epoch by 1 and returns the new value. It must be called inside an ambient transaction — the credential-invalidation funnel entry point guarantees this. Returns ErrAuthUserNotFound (KindNotFound) when no row matches userID.

fail-fast enforced: calling without an ambient transaction returns an error (errcode.ErrInternal); without a transaction the row update is auto-committed before the caller's surrounding atomic sequence completes.

func (*PGUserRepo) Create

func (r *PGUserRepo) Create(ctx context.Context, t tenant.TenantID, user *domain.User) error

Create inserts a new user row. Returns ErrAuthUserDuplicate on unique constraint violation (username or email already taken within the tenant).

func (*PGUserRepo) Delete

func (r *PGUserRepo) Delete(ctx context.Context, t tenant.TenantID, id string) error

Delete removes a user row. Returns ErrAuthUserNotFound when no row matched. Returns ErrAuthLastAdminProtected (403) when the migration-024 trigger on `users` rejects the delete because the row is the sole effective admin — same errcode + message as PGUserRepo.Update / domain.LastAdminGuard so client handlers match a single business invariant regardless of which layer caught the violation.

func (*PGUserRepo) GetByIDForUpdate

func (r *PGUserRepo) GetByIDForUpdate(ctx context.Context, t tenant.TenantID, id string) (*domain.User, error)

GetByIDForUpdate (S4d) — see ports.UserRepository godoc. Acquires a row lock via SELECT ... FOR UPDATE. The lookup is tenant-scoped (WHERE tenant_id=$1 AND id=$2) to prevent a cross-tenant read leak on the FOR UPDATE path. Tenant validation is performed inside getForUpdateBy (the shared convergence point for both lookup kinds); no redundant Validate call here.

func (*PGUserRepo) GetByIDInTenant

func (r *PGUserRepo) GetByIDInTenant(ctx context.Context, t tenant.TenantID, id string) (*domain.User, error)

GetByIDInTenant fetches a user by primary key within tenant t. Returns ErrAuthUserNotFound when the row is absent OR belongs to a different tenant — both cases produce pgx.ErrNoRows from `WHERE id=$1 AND tenant_id=$2`.

func (*PGUserRepo) GetByUsername

func (r *PGUserRepo) GetByUsername(ctx context.Context, t tenant.TenantID, username string) (*domain.User, error)

GetByUsername fetches a user by (tenant_id, username). Returns ErrAuthUserNotFound when absent.

func (*PGUserRepo) GetByUsernameForUpdate

func (r *PGUserRepo) GetByUsernameForUpdate(ctx context.Context, t tenant.TenantID, username string) (*domain.User, error)

GetByUsernameForUpdate (S4d) — see ports.UserRepository godoc. Tenant validation is performed inside getForUpdateBy; no redundant Validate call here.

func (*PGUserRepo) UpdateLockState

func (r *PGUserRepo) UpdateLockState(
	ctx context.Context,
	t tenant.TenantID,
	userID string,
	status domain.UserStatus,
	now time.Time,
) error

UpdateLockState writes status + updated_at, atomically resetting the three auto-lockout columns when status == StatusActive (SQL CASE clause). Returns ErrAuthLastAdminProtected when the migration-024 trigger blocks the change.

func (*PGUserRepo) UpdateLockoutFields

func (r *PGUserRepo) UpdateLockoutFields(ctx context.Context, t tenant.TenantID, user *domain.User) error

UpdateLockoutFields persists the auto-lockout state (failed_login_count, last_failed_at, locked_until, updated_at) for an existing user. Called exclusively from cells/accesscore/internal/accountlockout inside the sessionlogin tx. Returns ErrAuthUserNotFound when no row matched.

Schema note: this method touches only the four lockout-bookkeeping columns plus updated_at. The status / authz_epoch / password_hash columns remain owned by authzmutate.Mutator.ApplyInTx (Update / BumpAuthzEpoch) and the password-change path (UpdatePassword); they are NOT mutated here.

func (*PGUserRepo) UpdatePassword

func (r *PGUserRepo) UpdatePassword(
	ctx context.Context,
	t tenant.TenantID,
	userID string,
	newHash string,
	resetRequired bool,
	expectedPV int64,
) (int64, error)

UpdatePassword applies a CAS-guarded password write.

It executes updatePasswordSQL (WHERE id=$4 AND password_version=$5). If 0 rows were affected the method distinguishes "user absent" from "version mismatch" via a follow-up GetByID probe — callers receive ErrAuthUserNotFound or ErrVersionConflict respectively. On success the new password_version is returned.

func (*PGUserRepo) UpdatePasswordResetFlag

func (r *PGUserRepo) UpdatePasswordResetFlag(
	ctx context.Context,
	t tenant.TenantID,
	userID string,
	required bool,
	now time.Time,
) error

UpdatePasswordResetFlag writes password_reset_required + updated_at only.

func (*PGUserRepo) UpdateProfile

func (r *PGUserRepo) UpdateProfile(
	ctx context.Context,
	t tenant.TenantID,
	userID string,
	name, email *domain.NonEmpty,
	now time.Time,
) (*domain.User, error)

UpdateProfile writes username / email / updated_at. Nil name or email leaves that column unchanged (SQL COALESCE). Returns the post-write *domain.User reconstituted from the RETURNING row (explicit user column list); caller MUST use it as the new system-of-record aggregate.

Directories

Path Synopsis
internal
pgexec
Package pgexec is the sealed PostgreSQL executor funnel for the accesscore adapter.
Package pgexec is the sealed PostgreSQL executor funnel for the accesscore adapter.

Jump to

Keyboard shortcuts

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