Documentation
¶
Overview ¶
Package authzmutate is the single entry point for all authz-field mutations on a User aggregate. It enforces the Hard funnel invariant:
"mutate authz state without epoch-bump+revoke" is unrepresentable.
Every caller that needs to change a user's status or passwordResetRequired MUST go through Mutator.Apply.
Archtest enforcement (Wave 2) ¶
DOMAIN-AUTHZ-FIELD-PRIVATE-01 — domain.User authz fields are private; no exported field or new public setter exists.
AUTHZ-MUTATION-APPLY-FUNNEL-01 — callers of domain.User.SetStatus / SetPasswordResetRequired ⊆ {authzmutate/, domain _test.go}; callers of credentialinvalidate.Invalidator.Apply ⊆ {credentialinvalidate/, authzmutate/, identitymanage/, sessionrefresh/, rbacassign/}.
Rule (b) note: the broader caller set (vs the originally intended {authzmutate/, sessionrefresh/}) is the §A10-documented co-tx-atomicity necessity — identitymanage/ calls inv.Apply directly for Delete and changePasswordInTx (user-row-delete + revoke must be one transaction; routing through authzmutate would split those transactions); rbacassign/ similarly calls inv.Apply co-tx with the role-row write. These callers are NOT routable through authzmutate without introducing cross-tx correctness problems — see ADR §A10 "Co-tx atomicity constraint" for the proof. The write-side Hard guarantee comes from Rule (a) field privatization (DOMAIN-AUTHZ-FIELD-PRIVATE-01), NOT from Rule (b) caller-set closure. Rule (b) upper bound (Medium-by-necessity) is the Go type-system ceiling for tx-bound side-effect funnels; see ADR §A10 for ent/kratos evidence.
ADR §A10 → status "landed" (PR #494). RoleRevoked variant deleted (dead code — rbacassign.Revoke calls inv.Apply directly, never via this funnel).
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type ActivateUser ¶
type ActivateUser struct{}
ActivateUser re-activates a user. Additive — Invalidates() == false. Existing sessions remain valid; no epoch-bump needed. ref: ADR §A6 / OAuth Security BCP §4.13.2 (scope-expanding ops don't revoke).
Auto-lockout interaction: persist calls UpdateLockState(status=Active) which atomically zeros failed_login_count / last_failed_at / locked_until in the same SQL CASE statement (mem mirrors via ResetFailedLogins). This is a column-level invariant — "activate without lockout reset" is not expressible at the call site, closing the PR #585 P1#3 admin-unlock re-lock race at the schema layer. Both admin-unlock and lazy-unlock (accountlockout.TryLazyUnlock) routes through this mutation, so the reset is shared by both paths (ACCESSCORE-ACCOUNT-LOCKOUT-AUTO-LOCK-01). ref: ports.UserRepository.UpdateLockState godoc for the column-level invariant statement; PR #585 P1#3; ADR 202605222309.
func (ActivateUser) Event ¶
func (ActivateUser) Event() session.CredentialEvent
func (ActivateUser) Invalidates ¶
func (ActivateUser) Invalidates() bool
type ClearPasswordReset ¶
type ClearPasswordReset struct{}
ClearPasswordReset clears the password-reset-required flag. Additive — Invalidates() == false. The credential change (password rotation) was already handled by changePasswordInTx which called inv.Apply directly; this mutation only updates the domain flag.
func (ClearPasswordReset) Event ¶
func (ClearPasswordReset) Event() session.CredentialEvent
func (ClearPasswordReset) Invalidates ¶
func (ClearPasswordReset) Invalidates() bool
type LockUser ¶
type LockUser struct{}
LockUser locks the user account. Credential-weakening — Invalidates() == true.
func (LockUser) Event ¶
func (LockUser) Event() session.CredentialEvent
func (LockUser) Invalidates ¶
type Mutation ¶
type Mutation interface {
// Event returns the CredentialEvent label carried by this mutation.
//
// Contract: Event() is consumed ONLY when Invalidates()==true. The value
// is passed to inv.Apply → audit/credential-event routing and must be a
// meaningful CredentialEvent for those callers.
//
// For additive mutations (Invalidates()==false: ActivateUser,
// ClearPasswordReset) the return value is NEVER READ by any code path —
// Mutator.Apply skips inv.Apply entirely when Invalidates()==false.
// These implementations return the nearest-domain event purely to satisfy
// the total interface; the value is a documented don't-care and will never
// reach an audit row or session-revocation path.
Event() session.CredentialEvent
// Invalidates returns true when this mutation is a credential-weakening
// event that requires an epoch-bump + session/refresh revocation.
Invalidates() bool
// contains filtered or unexported methods
}
Mutation is the sealed interface for all authz-field change operations. Implementations are in this package only; the unexported mutationOK() method prevents external packages from satisfying the interface.
Invalidates() distinguishes additive operations (activate, clear-reset) from credential-weakening operations (lock, suspend, require-reset, role-revoke). Apply skips inv.Apply when Invalidates() == false — additive changes do not need an epoch-bump+revoke because no existing grants become too broad.
ActivateUser semantics (ADR §A6 / OAuth Security BCP §4.13.2): re-activating a user is an additive operation (scope-expanding, not scope-narrowing). It persists the status change but must NOT bump the authz_epoch or revoke sessions — existing sessions become MORE valid, not LESS. Invalidates() == false for ActivateUser.
ClearPasswordReset semantics: clearing the reset-required flag means the user has changed their password; the password-change path itself calls inv.Apply via changePasswordInTx, so clearing the flag here is a no-op on the invalidation side. Invalidates() == false for ClearPasswordReset.
type Mutator ¶
type Mutator struct {
// contains filtered or unexported fields
}
Mutator is the single entry point for all authz-field mutations on a User aggregate. It guarantees that every credential-weakening mutation (status change to locked/suspended, requirePasswordReset, role-revoke) atomically bumps authz_epoch + revokes sessions + revokes refresh chains via the credentialinvalidate funnel.
Additive mutations (activate, clear-reset) persist the domain-field change but skip the invalidation trifecta (ADR §A6).
tx boundary: Mutator no longer owns a RunInTx boundary. Callers MUST invoke ApplyInTx from within their own outer RunInTx closure. This ensures that the domain mutation + event publish co-commit in the same transaction (L2 OutboxFact guarantee).
func New ¶
func New( inv *credentialinvalidate.Invalidator, repo ports.UserRepository, ) (*Mutator, error)
New constructs a Mutator, fail-fasting on nil deps.
func (*Mutator) ApplyInTx ¶
func (a *Mutator) ApplyInTx( ctx context.Context, txCtx context.Context, tid tenant.TenantID, userID string, m Mutation, now time.Time, ) error
ApplyInTx executes the mutation scoped to tenant tid within the caller-provided transaction context txCtx. tid is derived by the caller via tenant.FromContext(ctx) (post-auth) or carried from the pre-auth login input (accountlockout, sessionlogin); passing it as an explicit param avoids fragility on pre-auth paths where ctx may not carry ctxkeys.TenantID. The caller MUST invoke ApplyInTx from within their own outer RunInTx closure so that the domain mutation, credential invalidation, and event publish all co-commit in the same transaction (L2 OutboxFact).
Steps:
- m.persist(txCtx, repo, tid, userID, now) — writes the mutation directly via the appropriate narrow port method (UpdateLockState / UpdatePasswordResetFlag). RowsAffected==0 → ErrAuthUserNotFound (KindNotFound) from the port; this replaces the prior GetByIDForUpdate round-trip with the same observable error surface.
- If m.Invalidates(), inv.Apply(txCtx, tid, userID, m.Event()) — bumps authz_epoch + revokes sessions + revokes refresh chains.
Preconditions: m must not be nil; userID must not be empty; txCtx must be an active transaction context obtained from the caller's RunInTx closure.
type RequirePasswordReset ¶
type RequirePasswordReset struct{}
RequirePasswordReset marks the user as requiring a password reset. Credential-weakening — Invalidates() == true (existing sessions should be revoked so the user is forced through the reset flow immediately).
func (RequirePasswordReset) Event ¶
func (RequirePasswordReset) Event() session.CredentialEvent
func (RequirePasswordReset) Invalidates ¶
func (RequirePasswordReset) Invalidates() bool
type SuspendUser ¶
type SuspendUser struct{}
SuspendUser suspends the user account. Credential-weakening — Invalidates() == true.
suspend≡lock for credential revocation (intentional, ADR-consistent): Event() returns CredentialEventLock rather than a hypothetical CredentialEventSuspend. This is not a bug — session.CredentialEvent is a sealed, completeness-checked set (WithRevokeOnAll / NewProtocol / ValidateCredentialEvent / String / all Store.RevokeForSubject impls) with no Suspend member by design. The project canonically treats suspend as equivalent to Lock for the purpose of session/refresh revocation: both states make the user non-authenticable and must revoke all active tokens. Precedent: identitymanage.cascadeInvalidateOnDemotion godoc explicitly states "suspended semantics are equivalent to Lock".
func (SuspendUser) Event ¶
func (SuspendUser) Event() session.CredentialEvent
func (SuspendUser) Invalidates ¶
func (SuspendUser) Invalidates() bool