Documentation
¶
Overview ¶
Package mem provides in-memory repository implementations for accesscore.
Locking model ¶
All UserRepository and RoleRepository instances vended by NewStore share a single sync.Mutex (store.mu) and underlying maps, so cross-repo invariants (notably the at-least-one-effective-admin invariant: S4.0) can be enforced atomically. This mirrors the atomicity properties of the PG adapter (advisory xact lock + FOR UPDATE OF u in cells/accesscore/internal/adapters/postgres/role_repo.go) so unit tests built on mem behave the same as the production PG path under concurrent mutation.
There is no "standalone" mem.UserRepository / mem.RoleRepository constructor (S4.0 removed the per-repo NewXxx functions). All callers must go through NewStore to make the shared-state choice explicit and prevent accidental dual-store wiring that would silently lose cross-repo atomicity.
Single-lock rule with a self-invalidating lock-ownership lease (#945, #972) ¶
store.mu is the sole synchronization primitive for all map state. There are exactly two lock-acquisition sites:
memTxRunner.runLocked (the body of RunInTx) — acquires store.mu for the entire transaction closure via txlock.Acquire(&store.mu). This gives all tx-path operations serialized, atomic access equivalent to PG SELECT FOR UPDATE held until commit.
Individual repository methods called OUTSIDE a held lock — each acquires store.mu for its own read or write, then releases it before returning.
The ctx carries a txlock.Lease value (see internal/txlock), NOT a bool and NOT a wrapper struct. Only runLocked — which has just called txlock.Acquire(&store.mu) — injects a live lease. Repository methods skip their per-call lock acquisition ONLY when Store.inLiveTx(ctx) reports the lease is live AND bound to THIS store's mutex:
- inLiveTx true (lease live, lease.mu == &store.mu) : runLocked holds store.mu for the whole closure → skip the per-call lock (sync.Mutex is not reentrant; re-acquiring would deadlock).
- otherwise (no lease / dead lease / foreign store's mutex) : acquire store.mu per call.
Two AI-robust axes, both carried by the sealed txlock package (not archtest):
- Forge (upstream Hard): txlock.Lease's fields are unexported, so the ONLY way to obtain a lease whose Live(&store.mu) is true is txlock.Acquire(&store.mu), which actually Lock()s the mutex. "In a tx context yet not holding the lock, so skip locking" is inexpressible — not even inside package mem (mem may call Acquire, but Acquire locks; mem cannot forge a live lease through a struct literal).
- Liveness (downstream Hard): the lease self-invalidates. Acquire's unlock closure (the sole writer of the live flag) flips it dead before store.mu is released. A ctx that escapes the runLocked closure — captured by a goroutine or an after-commit hook — therefore reports inLiveTx==false afterwards and falls back to per-call locking (fail-closed). This closes the capability-escape that the earlier pointer-only witness left open (#972): the witness proved the mutex was locked at SOME point, never that it is locked NOW. Mirrors database/sql *Tx.done → ErrTxDone invalidation on completion.
History: the original bool sentinel (pre-#945) could not distinguish "RunInTx holds the lock" from "a non-locking fake injected the sentinel"; a fake making repo methods skip locking with no lock held caused concurrent map writes under multi-goroutine load (fatal error: concurrent map writes; flake fixed by PR fix/238 → #945 witness → #972 lease). The model aligns with ent (*Tx in context), GORM (in-tx via ConnPool type assertion) and Kratos (*queries.Queries in context): the context carries a strongly-typed, resource-bound ownership object, never a bool. See ADR docs/architecture/202605171846-adr-mem-tx-lock-ownership.md.
Concurrency boundary: the lease guards AFTER-RETURN escape, not CONCURRENT-DURING-TX use. Two goroutines sharing one LIVE tx ctx (e.g. a goroutine spawned inside the closure that touches the repo with the inner ctx while the parent still holds the lock) both see inLiveTx==true and skip locking → the non-holder races the maps. This is the database/sql "*Tx must not be used concurrently by multiple goroutines" boundary and is out of scope for the lease, exactly as ErrTxDone does not make *sql.Tx concurrency-safe. Operational rule: a goroutine spawned inside the closure MUST NOT use the inner ctx to call repository methods — pass context.Background() (or hand results back over a channel) so the goroutine takes its own per-call lock.
MEM-STORE-RWMUTEX-READ-CONCURRENCY: store.mu could become sync.RWMutex so outside-tx read methods take RLock (cf. client-go ThreadSafeStore). Deferred — orthogonal to the flake root fix and independently verifiable.
ForUpdate variants (GetByIDForUpdate, GetByUsernameForUpdate) follow the same rule: inside RunInTx they read under the held store.mu (full FOR-UPDATE-until-commit serialization); under a foreign CellTxManager they take store.mu per call (functional fallback, no cross-statement serialization). They never hard-fail on the TxRunner pairing — see #501 (that broke corebundle/ssobff/demo logins).
Index ¶
- type PolicyRepository
- func (r *PolicyRepository) Delete(ctx context.Context, t tenant.TenantID, id string) error
- func (r *PolicyRepository) GetByID(ctx context.Context, t tenant.TenantID, id string) (*abac.Policy, error)
- func (r *PolicyRepository) ListByTenant(ctx context.Context, t tenant.TenantID) ([]*abac.Policy, error)
- func (r *PolicyRepository) Save(ctx context.Context, t tenant.TenantID, p *abac.Policy) error
- type RoleRepository
- func (r *RoleRepository) AssignToUser(ctx context.Context, t tenant.TenantID, userID, roleID string) (bool, error)
- func (r *RoleRepository) CountByRole(ctx context.Context, t tenant.TenantID, roleID string) (int, error)
- func (r *RoleRepository) CountEffectiveAdmins(ctx context.Context, t tenant.TenantID) (int, error)
- func (r *RoleRepository) Create(ctx context.Context, t tenant.TenantID, role *domain.Role) error
- func (r *RoleRepository) EffectiveAdminExists(ctx context.Context, t tenant.TenantID) (bool, error)
- func (r *RoleRepository) GetByID(ctx context.Context, t tenant.TenantID, id string) (*domain.Role, error)
- func (r *RoleRepository) GetByUserID(ctx context.Context, t tenant.TenantID, userID string) ([]*domain.Role, error)
- func (r *RoleRepository) ListByUserID(ctx context.Context, t tenant.TenantID, userID string, params query.ListParams) ([]*domain.Role, error)
- func (r *RoleRepository) RemoveFromUser(ctx context.Context, t tenant.TenantID, userID, roleID string) error
- func (r *RoleRepository) RemoveFromUserIfNotLast(ctx context.Context, t tenant.TenantID, userID, roleID string) (bool, error)
- func (r *RoleRepository) SeedRole(t tenant.TenantID, role *domain.Role)
- func (r *RoleRepository) SeedUserRoleAssignment(t tenant.TenantID, userID, roleID string)
- type Store
- type UserRepository
- func (r *UserRepository) BumpAuthzEpoch(ctx context.Context, t tenant.TenantID, userID string, ...) (int64, error)
- func (r *UserRepository) Create(ctx context.Context, t tenant.TenantID, user *domain.User) error
- func (r *UserRepository) Delete(ctx context.Context, t tenant.TenantID, id string) error
- func (r *UserRepository) GetByIDForUpdate(ctx context.Context, t tenant.TenantID, id string) (*domain.User, error)
- func (r *UserRepository) GetByIDInTenant(ctx context.Context, t tenant.TenantID, id string) (*domain.User, error)
- func (r *UserRepository) GetByUsername(ctx context.Context, t tenant.TenantID, username string) (*domain.User, error)
- func (r *UserRepository) GetByUsernameForUpdate(ctx context.Context, t tenant.TenantID, username string) (*domain.User, error)
- func (r *UserRepository) UpdateLockState(ctx context.Context, t tenant.TenantID, userID string, ...) error
- func (r *UserRepository) UpdateLockoutFields(ctx context.Context, t tenant.TenantID, user *domain.User) error
- func (r *UserRepository) UpdatePassword(ctx context.Context, t tenant.TenantID, userID string, newHash string, ...) (int64, error)
- func (r *UserRepository) UpdatePasswordResetFlag(ctx context.Context, t tenant.TenantID, userID string, required bool, ...) error
- func (r *UserRepository) UpdateProfile(ctx context.Context, t tenant.TenantID, userID string, ...) (*domain.User, error)
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type PolicyRepository ¶
type PolicyRepository struct {
// contains filtered or unexported fields
}
PolicyRepository is the in-memory implementation of ports.PolicyRepository.
Unlike RoleRepository, PolicyRepository owns its own mutex and its own tenant-partitioned map — Policies have no cross-repo invariant with roles or users (no at-most-one-admin style invariant), so folding this into the shared Store mutex would add contention without correctness benefit.
All methods are tenant-scoped (#1337 PR-6): the tenant.TenantID positional parameter selects the per-tenant partition of the underlying map.
Clone discipline: every read path (GetByID, ListByTenant) returns a deep copy of the stored Policy so callers cannot mutate stored state through the returned pointer.
func NewPolicyRepository ¶
func NewPolicyRepository() *PolicyRepository
NewPolicyRepository constructs an empty in-memory PolicyRepository.
func (*PolicyRepository) Delete ¶
Delete removes the policy identified by id from the tenant. Returns KindNotFound when the policy does not exist in t.
func (*PolicyRepository) GetByID ¶
func (r *PolicyRepository) GetByID(ctx context.Context, t tenant.TenantID, id string) (*abac.Policy, error)
GetByID returns the policy identified by id within the tenant. Returns a defensive clone so callers cannot mutate stored state. Returns KindNotFound when the policy does not exist.
func (*PolicyRepository) ListByTenant ¶
func (r *PolicyRepository) ListByTenant(ctx context.Context, t tenant.TenantID) ([]*abac.Policy, error)
ListByTenant returns all policies owned by the tenant. Returns an empty (non-nil) slice when the tenant has no policies. Each returned policy is a defensive clone.
type RoleRepository ¶
type RoleRepository struct {
// contains filtered or unexported fields
}
RoleRepository is the in-memory implementation of ports.RoleRepository. It is always vended by Store.RoleRepository() so the shared mutex covers any cross-repo invariant — most importantly, CountEffectiveAdmins and the admin branch of RemoveFromUserIfNotLast read user.Status atomically with the role_assignments state, mirroring the PG advisory-lock + FOR UPDATE guarantees in role_repo.go.
All methods are tenant-scoped (#1337 PR-2): the tenant.TenantID positional parameter selects the per-tenant partition of the underlying maps.
func (*RoleRepository) AssignToUser ¶
func (r *RoleRepository) AssignToUser(ctx context.Context, t tenant.TenantID, userID, roleID string) (bool, error)
AssignToUser assigns roleID to userID within the tenant. Safe to call both inside and outside a RunInTx closure; see the lock contract on UserRepository.
F4: verifies that the user belongs to tenant t before inserting the assignment — mirrors the PG composite FK (user_id, tenant_id) added by Agent A. Returns ErrAuthUserNotFound when the user does not exist in t.
func (*RoleRepository) CountByRole ¶
func (r *RoleRepository) CountByRole(ctx context.Context, t tenant.TenantID, roleID string) (int, error)
CountByRole returns the total count of role_assignments for roleID within the tenant, regardless of user status. Used for bootstrap idempotency (adminprovision); MUST NOT be used as the last-admin invariant counter — see CountEffectiveAdmins.
func (*RoleRepository) CountEffectiveAdmins ¶
CountEffectiveAdmins returns the number of users that are simultaneously status='active' AND hold the admin role within the tenant. Satisfies the domain.EffectiveAdminCounter sealed interface (S4.0 invariant counter).
func (*RoleRepository) Create ¶
Create persists a new role within the tenant. Idempotent: if a role with the same ID already exists in the tenant, it is silently overwritten (upsert semantics for seed/bootstrap). Safe to call both inside and outside a RunInTx closure; see the lock contract on UserRepository.
func (*RoleRepository) EffectiveAdminExists ¶
EffectiveAdminExists implements ports.RoleRepository — see the port godoc for fast-path semantics. Returns true on the first match within the tenant.
func (*RoleRepository) GetByID ¶
func (r *RoleRepository) GetByID(ctx context.Context, t tenant.TenantID, id string) (*domain.Role, error)
GetByID returns the Role with the given ID within the tenant. Safe to call both inside and outside a RunInTx closure; see the lock contract on UserRepository.
func (*RoleRepository) GetByUserID ¶
func (r *RoleRepository) GetByUserID(ctx context.Context, t tenant.TenantID, userID string) ([]*domain.Role, error)
GetByUserID returns all roles assigned to userID within the tenant. Safe to call both inside and outside a RunInTx closure; see the lock contract on UserRepository.
func (*RoleRepository) ListByUserID ¶
func (r *RoleRepository) ListByUserID( ctx context.Context, t tenant.TenantID, userID string, params query.ListParams, ) ([]*domain.Role, error)
ListByUserID returns paginated roles for userID within the tenant sorted per params. Safe to call both inside and outside a RunInTx closure.
func (*RoleRepository) RemoveFromUser ¶
func (r *RoleRepository) RemoveFromUser(ctx context.Context, t tenant.TenantID, userID, roleID string) error
RemoveFromUser removes roleID from userID within the tenant unconditionally. Safe to call both inside and outside a RunInTx closure.
func (*RoleRepository) RemoveFromUserIfNotLast ¶
func (r *RoleRepository) RemoveFromUserIfNotLast(ctx context.Context, t tenant.TenantID, userID, roleID string) (bool, error)
RemoveFromUserIfNotLast atomically removes the admin role from a user only when removing the assignment would not leave the tenant with zero effective admins. Mirrors the PG removeIfNotLastSQL per-tenant CTE semantics (#1337 PR-2).
func (*RoleRepository) SeedRole ¶
func (r *RoleRepository) SeedRole(t tenant.TenantID, role *domain.Role)
SeedRole adds a role directly into the store for testing purposes. It always acquires the lock because seed calls are never inside a RunInTx.
func (*RoleRepository) SeedUserRoleAssignment ¶
func (r *RoleRepository) SeedUserRoleAssignment(t tenant.TenantID, userID, roleID string)
SeedUserRoleAssignment directly inserts a user-role mapping into the store, bypassing the F4 user-in-tenant check. Use ONLY in tests that exercise role semantics in isolation (e.g. without a paired UserRepository in the same store). Production paths must use AssignToUser, which enforces the check.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is the shared backing for an in-memory accesscore deployment. The embedded mutex protects all maps; UserRepository and RoleRepository derived from a single Store cooperate through this lock to implement atomic cross-repo invariants without leaking storage details across the repo boundary.
Tenancy layout (#1337 PR-2) ¶
To mirror the PG schema (tenant-scoped unique indices), the following maps use tenant-scoped keys:
- usersByID: global UUID PK (not tenant-scoped — mirrors PG users.id PK)
- byName: [tenantID][username] → *User (composite unique per tenant)
- byEmail: [tenantID][email] → *User (composite unique per tenant)
- userRoles: [tenantID][userID] → set of roleIDs (per-tenant assignments)
- roles: [tenantID][roleID] → *Role (per-tenant role definitions)
TxRunner is the source of the Store-paired TxRunner that delivers full FOR-UPDATE-until-commit serialization. Wiring a different TxRunner (e.g. outbox.DemoTxRunner, or a PG tx manager in corebundle's mixed-topology e2e) is still functional — every repo method then takes store.mu per call — but the cross-statement serialization guarantee then holds only on the Store-TxRunner path. mem never hard-fails on the pairing (#501).
func NewStore ¶
NewStore constructs an empty shared Store. clk must be non-nil; mem repositories rely on it for timestamping CAS-guarded password updates.
func (*Store) RoleRepository ¶
func (s *Store) RoleRepository() *RoleRepository
RoleRepository returns the RoleRepository view of s. All instances returned from a single Store share state.
func (*Store) TxRunner ¶
func (s *Store) TxRunner() persistence.TxRunner
TxRunner returns a Store-bound persistence.TxRunner. It acquires store.mu for the entire RunInTx closure and injects a live txlock.Lease so that repository methods skip their individual lock acquisitions.
This is the only correct TxRunner to use with repos vended by this Store. Composition roots and test helpers must call:
persistence.WrapForCell(store.TxRunner())
Using any other TxRunner (including outbox.DemoTxRunner or a fake that does not hold store.mu) does not break safety — repo methods fall back to per-call locking (no live lease in ctx) — but it forfeits cross-method serialization. As a visible consequence, ChangePassword may then return ErrAuthOldPasswordIncorrect (if a concurrent write replaces the hash between the read and the bcrypt comparison) in addition to ErrVersionConflict; the Store-paired TxRunner's FOR-UPDATE-until-commit serialization prevents this.
func (*Store) UserRepository ¶
func (s *Store) UserRepository() *UserRepository
UserRepository returns the UserRepository view of s. All instances returned from a single Store share state; constructing multiple Stores produces independent state spaces (typically wrong for production wiring).
type UserRepository ¶
type UserRepository struct {
// contains filtered or unexported fields
}
UserRepository is the in-memory implementation of ports.UserRepository. It is always vended by Store.UserRepository() so the shared mutex covers any cross-repo invariant (e.g. effective-admin checks in RoleRepository).
Tenancy (#1337 PR-2 + PR-3b) ¶
Methods take a mandatory tenant.TenantID positional parameter and scope all reads/writes to the tenant's partition of the underlying maps. After PR-3b the tenant-less GetByID carve-out is removed; all by-PK reads are tenant-scoped.
Lock contract ¶
Methods on UserRepository follow the single-lock rule (see store.go package godoc). Each method checks r.store.inLiveTx(ctx):
- inLiveTx==true: store.mu is already held by memTxRunner.RunInTx on the calling goroutine (a live lease is in ctx) — do NOT acquire store.mu (sync.Mutex is not reentrant; re-acquiring would deadlock).
- inLiveTx==false (no lease / dead lease / foreign store): acquire store.mu for the duration of this method call.
func (*UserRepository) BumpAuthzEpoch ¶
func (r *UserRepository) BumpAuthzEpoch( ctx context.Context, t tenant.TenantID, userID string, tok credentialfence.FenceToken, ) (int64, error)
BumpAuthzEpoch atomically increments the AuthzEpoch counter for the given user and returns the new value. Safe to call both inside and outside a RunInTx closure. The lookup is tenant-scoped via userByIDInTenant to prevent a cross-tenant epoch bump, mirroring the PG bumpAuthzEpochSQL tenant predicate.
func (*UserRepository) Create ¶
Create persists a new User within the tenant. Safe to call both inside and outside a RunInTx closure; see UserRepository lock contract.
func (*UserRepository) Delete ¶
Delete removes the User with the given ID within the tenant. Safe to call both inside and outside a RunInTx closure.
func (*UserRepository) GetByIDForUpdate ¶
func (r *UserRepository) GetByIDForUpdate(ctx context.Context, t tenant.TenantID, id string) (*domain.User, error)
GetByIDForUpdate (S4d): mem implementation of SELECT ... FOR UPDATE semantics. Tenant-scoped: callers are post-auth and carry a tenant. After PR-3b this delegates directly to GetByIDInTenant (the former GetByID carve-out is removed). The mem store serializes via store.mu held in RunInTx — for details see UserRepository lock contract.
func (*UserRepository) GetByIDInTenant ¶
func (r *UserRepository) GetByIDInTenant(ctx context.Context, t tenant.TenantID, id string) (*domain.User, error)
GetByIDInTenant fetches a user by primary key and verifies it belongs to t. Returns ErrAuthUserNotFound when the row is absent OR in a different tenant, collapsing both cases to prevent cross-tenant existence enumeration.
func (*UserRepository) GetByUsername ¶
func (r *UserRepository) GetByUsername(ctx context.Context, t tenant.TenantID, username string) (*domain.User, error)
GetByUsername returns the User with the given username within the tenant. Safe to call both inside and outside a RunInTx closure.
func (*UserRepository) GetByUsernameForUpdate ¶
func (r *UserRepository) GetByUsernameForUpdate(ctx context.Context, t tenant.TenantID, username string) (*domain.User, error)
GetByUsernameForUpdate (S4d): username-keyed counterpart to GetByIDForUpdate, scoped to the tenant. Delegates to GetByUsername for the same reason — mem has no row lock distinct from the plain read.
func (*UserRepository) UpdateLockState ¶
func (r *UserRepository) UpdateLockState( ctx context.Context, t tenant.TenantID, userID string, status domain.UserStatus, now time.Time, ) error
UpdateLockState writes status + updated_at within the tenant, and atomically zeros the auto-lockout columns when status == StatusActive. Safe to call both inside and outside a RunInTx closure.
func (*UserRepository) UpdateLockoutFields ¶
func (r *UserRepository) UpdateLockoutFields(ctx context.Context, t tenant.TenantID, user *domain.User) error
UpdateLockoutFields persists the auto-lockout state for an existing user within the tenant. Safe to call both inside and outside a RunInTx closure.
func (*UserRepository) UpdatePassword ¶
func (r *UserRepository) UpdatePassword( ctx context.Context, t tenant.TenantID, userID string, newHash string, resetRequired bool, expectedPV int64, ) (int64, error)
UpdatePassword applies a CAS-guarded password update within the tenant. Safe to call both inside and outside a RunInTx closure.
func (*UserRepository) UpdatePasswordResetFlag ¶
func (r *UserRepository) UpdatePasswordResetFlag( ctx context.Context, t tenant.TenantID, userID string, required bool, now time.Time, ) error
UpdatePasswordResetFlag writes password_reset_required + updated_at only within the tenant. Safe to call both inside and outside a RunInTx closure.
func (*UserRepository) UpdateProfile ¶
func (r *UserRepository) 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 only within the tenant. PATCH semantics: nil name/email skips that column. Returns the reconstituted *domain.User. Safe to call both inside and outside a RunInTx closure.