package auth import ( "context" "encoding/json" "errors" "log/slog" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/missionworkforce/missionmatch/internal/platform/audit" "github.com/missionworkforce/missionmatch/internal/platform/security" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // ErrEmailInUse is returned when an invite targets an email that already exists (global uniqueness). var ErrEmailInUse = errors.New("auth: email already in use") // targetUser is the org scoped view of a user read inside a tenant tx. Because the read runs under // RLS, a row only comes back when the target is in the actor's org, which is the same org proof. type targetUser struct { id uuid.UUID email string role string status string } // Invite creates an invited user in the actor's org and a one time activation token. The capability // (user.invite) is enforced by middleware; the separation of duties bound (invited role strictly // below the actor) is enforced here. func (s *Service) Invite(ctx context.Context, in InviteInput, ip, ua string) (InviteResult, error) { if _, err := s.db(); err != nil { return InviteResult{}, err } actor, ok := tenant.UserFromContext(ctx) if !ok { return InviteResult{}, ErrForbidden } org, ok := tenant.OrgFromContext(ctx) if !ok { return InviteResult{}, tenant.ErrNoTenant } if !security.ValidRole(security.Role(in.Role)) || !security.CanInvite(security.Role(actor.Role), security.Role(in.Role)) { // Record the over authority attempt in the org's own audit trail (in tenant), matching the // ChangeRole denial, so a privilege escalation attempt is visible to the org, not only the // platform NULL org log. // // The write error is RETURNED, not discarded. Discarding it meant a repeated escalation attempt // could leave no trace at all while the attacker still received a clean, informative 403, which is // the one combination a prober wants: an attempt that fails loudly to them and silently to // everyone else. The module's fail closed audit contract (see auditSessionRejected and the audit // package doc) says a security event that cannot be recorded fails the request instead. The caller // gets no invite either way, so failing closed costs a legitimate over authority attempt nothing // but a 500 in place of a 403, and it costs a prober their silence. // // ErrForbidden is returned AFTER the transaction rather than from inside the closure. Returning // the denial from inside would roll the transaction back and take the audit row with it, which is // the very record this exists to keep. if e := s.deps.DB.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { return s.auditTx(ctx, tx, actor, audit.Event{ Action: "authz.deny", EntityType: "capability", EntityID: "user.invite", IPAddress: ip, UserAgent: ua, NewValues: reasonJSON("invited role not below actor")}) }); e != nil { return InviteResult{}, e } return InviteResult{}, ErrForbidden } token, tokenHash, err := security.NewSessionToken() if err != nil { return InviteResult{}, err } want := security.ToolsOf(in.Tools) var newUserID uuid.UUID var elsewhere bool err = s.deps.DB.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { id, other, e := s.inviteTx(ctx, tx, actor, org, in, want, tokenHash, ip, ua) newUserID, elsewhere = id, other return e }) if err != nil { return InviteResult{}, err } if elsewhere { // The email belongs to an account in a DIFFERENT organization, which this actor is not allowed to // learn. Email is globally unique (one human, one account, one org), so a distinguishable answer // here turned the invite endpoint into a cross tenant directory: any tenant admin could probe an // address and read 409 as "this person is a customer of ours somewhere" and 200 as "they are not". // The response is therefore shaped exactly like a success, and nothing is created. // // The token handed back is a fresh random value that was never stored, so redeeming it fails the // same way an expired link does. That is the cost of the fix: an admin who happens to invite an // address already claimed elsewhere gets a link that does not work rather than an explanation. // Support resolves it from the audit trail, which records the collision inside the tenant, while // the requester learns nothing they did not already know. decoy, _, derr := security.NewSessionToken() if derr != nil { return InviteResult{}, derr } return InviteResult{Token: decoy, UserID: uuid.NewString()}, nil } return InviteResult{Token: token, UserID: newUserID.String()}, nil } // inviteTx is the in transaction half of Invite. It reports (newUserID, emailClaimedElsewhere, error): // emailClaimedElsewhere true means the address exists in another organization, which the caller turns // into a success shaped non answer. func (s *Service) inviteTx(ctx context.Context, tx pgx.Tx, actor tenant.Principal, org string, in InviteInput, want []string, tokenHash []byte, ip, ua string) (uuid.UUID, bool, error) { // Probe the actor's OWN org first, under RLS, so a row comes back only when the address is already // theirs. Re inviting somebody who is already in your organization keeps a distinguishable 409: // it discloses nothing the admin cannot read on the People screen, and swallowing it would make a // routine mistake look like it worked. var existing uuid.UUID e := tx.QueryRow(ctx, `SELECT id FROM users WHERE lower(email) = lower($1)`, in.Email).Scan(&existing) if e == nil { return uuid.Nil, false, ErrEmailInUse } if !errors.Is(e, pgx.ErrNoRows) { return uuid.Nil, false, e } // The insert runs inside a savepoint so that a unique violation from ANOTHER org's row, which the // probe above cannot see through RLS, aborts only this statement. The outer transaction survives and // can still commit the collision audit record; without the savepoint the aborted transaction would // take the audit entry with it and the collision would leave no trace at all. sp, e := tx.Begin(ctx) if e != nil { return uuid.Nil, false, e } defer func() { _ = sp.Rollback(ctx) }() // no op once committed var newUserID uuid.UUID e = sp.QueryRow(ctx, `INSERT INTO users (organization_id, email, full_name, role, status) VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid, $1, $2, $3, 'invited') RETURNING id`, in.Email, in.FullName, in.Role).Scan(&newUserID) if isUniqueViolation(e) { if rerr := sp.Rollback(ctx); rerr != nil { return uuid.Nil, false, rerr } // Audited on the OUTER transaction, and deliberately without the email address: the point of the // record is that a probe happened, and writing the probed address into the tenant's own audit log // would put the answer back where the requester can read it. return uuid.Nil, true, s.auditTx(ctx, tx, actor, audit.Event{ Action: "user.invite.email_claimed_elsewhere", EntityType: "user", IPAddress: ip, UserAgent: ua, NewValues: reasonJSON("email already registered outside this organization")}) } if e != nil { return uuid.Nil, false, e } if _, e := sp.Exec(ctx, `SELECT auth_create_invite($1,$2,$3,$4,$5,$6,$7)`, mustUUID(org), in.Email, newUserID, tokenHash, in.Role, s.deps.InviteSeconds, mustUUID(actor.UserID)); e != nil { return uuid.Nil, false, e } // Entitlement is granted in the SAME transaction as the account, so an invite either produces a // usable account or produces nothing. Because tool access fails closed, an account created without // this would exist and see no product at all, and the operator would have to notice and fix it. // // The inviter may only confer tools they hold themselves. Without that bound, invite would be the // way around the possession rule SetUserTools enforces: grant yourself a tool via a new account. if len(want) > 0 { actorTools, e := readTools(ctx, sp, mustUUID(actor.UserID)) if e != nil { return uuid.Nil, false, e } if !security.CanConferTools(actorTools, want) { _ = s.auditTx(ctx, sp, actor, audit.Event{Action: "authz.deny", EntityType: "user", EntityID: newUserID.String(), IPAddress: ip, UserAgent: ua, NewValues: reasonJSON("invite tool grant above authority")}) return uuid.Nil, false, ErrForbidden } for _, w := range want { if _, e := sp.Exec(ctx, `INSERT INTO user_tool_access (organization_id, user_id, tool, granted_by) VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid, $1, $2, $3)`, newUserID, w, mustUUID(actor.UserID)); e != nil { return uuid.Nil, false, e } } } if e := s.auditTx(ctx, sp, actor, audit.Event{Action: "user.invite.created", EntityType: "user", EntityID: newUserID.String(), IPAddress: ip, UserAgent: ua}); e != nil { return uuid.Nil, false, e } return newUserID, false, sp.Commit(ctx) } // AcceptInvite redeems an invite or activation token and sets the password. With MFA required it // issues an auth_pending session so the new user proceeds to MFA enrollment; with MFA disabled for a // pilot it issues an active session directly. It is public and token keyed. func (s *Service) AcceptInvite(ctx context.Context, in AcceptInput, ip, ua string) (LoginResult, error) { db, err := s.db() if err != nil { return LoginResult{}, err } hash, ver, err := s.deps.Hasher.Hash(in.Password) if err != nil { return LoginResult{}, err // includes ErrPasswordTooLong } var userID, orgID uuid.UUID err = db.QueryRow(ctx, `SELECT user_id, organization_id FROM auth_accept_invite($1,$2,$3)`, security.HashSessionToken(in.Token), hash, ver).Scan(&userID, &orgID) if errors.Is(err, pgx.ErrNoRows) { return LoginResult{}, ErrInvalidCredentials // invalid, expired, used, or a mismatched invite } if err != nil { return LoginResult{}, err } token, tokenHash, err := security.NewSessionToken() if err != nil { return LoginResult{}, err } if err := s.createInitialSession(ctx, db, tokenHash, userID, orgID, ip, ua); err != nil { return LoginResult{}, err } _ = s.deps.Audit.System(ctx, audit.Event{Action: "user.invite.accepted", ActorUserID: &userID, IPAddress: ip, UserAgent: ua}) return LoginResult{Token: token, MFAEnrolled: false, MFARequired: !s.deps.MFADisabled}, nil } // ReissueRecovery issues a fresh single use activation link for a target user after proving same org // membership through an RLS scoped read. The target redeems it to set a new password. This is the // admin recovery path; public self serve forgot password is [credential removed] to sub phase 2.9. func (s *Service) ReissueRecovery(ctx context.Context, targetID string, ip, ua string) (InviteResult, error) { if _, err := s.db(); err != nil { return InviteResult{}, err } actor, ok := tenant.UserFromContext(ctx) if !ok { return InviteResult{}, ErrForbidden } org, ok := tenant.OrgFromContext(ctx) if !ok { return InviteResult{}, tenant.ErrNoTenant } token, tokenHash, err := security.NewSessionToken() if err != nil { return InviteResult{}, err } var target targetUser var denied *denial err = s.deps.DB.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { t, e := readTarget(ctx, tx, targetID) if e != nil { return e } target = t if !security.OutRanks(security.Role(actor.Role), security.Role(t.role)) { return ErrForbidden } // A DISABLED account must not be handed a fresh activation link. ChangeStatus revokes sessions on // disable and ChangeRole revokes on demotion, so both privilege reduction paths are careful; this // one checked only rank. The token it returns is redeemed by the PUBLIC accept endpoint with a // password of the redeemer's choosing, which turns account recovery into a way to resurrect an // account another administrator disabled during an incident. if t.status == "disabled" { denied = &denial{entityType: "user", entityID: t.id.String(), reason: "recovery reissue for a disabled account"} return ErrForbidden } if _, e := tx.Exec(ctx, `SELECT auth_create_invite($1,$2,$3,$4,$5,$6,$7)`, mustUUID(org), t.email, t.id, tokenHash, t.role, s.deps.InviteSeconds, mustUUID(actor.UserID)); e != nil { return e } return s.auditTx(ctx, tx, actor, audit.Event{Action: "user.recovery.reissued", EntityType: "user", EntityID: t.id.String(), IPAddress: ip, UserAgent: ua}) }) if denied != nil { s.recordDenial(ctx, actor, *denied, ip, ua) } if err != nil { return InviteResult{}, err } return InviteResult{Token: token, UserID: target.id.String()}, nil } // ChangeRole sets a target user's role, enforcing that the actor outranks both the current and the // new role and never changes their own role. Because no role outranks owner, owner accounts cannot be // demoted or promoted to through the API; owner replacement is a migrator operation. It revokes the // target's sessions so a downgrade cannot ride an old elevated session. func (s *Service) ChangeRole(ctx context.Context, targetID string, in RoleChangeInput, ip, ua string) error { if _, err := s.db(); err != nil { return err } actor, ok := tenant.UserFromContext(ctx) if !ok { return ErrForbidden } if actor.UserID == targetID { return ErrForbidden // no self role change } if !security.ValidRole(security.Role(in.Role)) { return ErrBadRequest } var denied *denial err := s.deps.DB.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { t, e := readTarget(ctx, tx, targetID) if e != nil { return e } // Two distinct checks, and the asymmetry is deliberate. // // The TARGET check stays OutRanks: an actor may only act on an account strictly below them, so a // peer can never demote a peer. That protects a superuser from another superuser, which is why // promoting someone to the tier is a one way action rather than a lever over an existing holder. // // The NEW ROLE check is CanGrantRole, which permits the one exception the ladder needs: a // superuser may confer superuser. Using OutRanks here too would make the tier unable to // reproduce, so it could only ever shrink. if !security.OutRanks(security.Role(actor.Role), security.Role(t.role)) || !security.CanGrantRole(security.Role(actor.Role), security.Role(in.Role)) { denied = &denial{entityType: "user", entityID: t.id.String(), reason: "role change above authority"} return ErrForbidden } if _, e := tx.Exec(ctx, `UPDATE users SET role = $1, updated_at = now() WHERE id = $2`, in.Role, t.id); e != nil { return e } if _, e := tx.Exec(ctx, `SELECT auth_revoke_user_sessions($1,$2)`, t.id, nil); e != nil { return e } return s.auditTx(ctx, tx, actor, audit.Event{ Action: "authz.permission.change", EntityType: "user", EntityID: t.id.String(), OldValues: reasonRole(t.role), NewValues: reasonRole(in.Role), IPAddress: ip, UserAgent: ua}) }) // Written after the transaction has rolled back, so the denial survives the refusal that caused it. if denied != nil { s.recordDenial(ctx, actor, *denied, ip, ua) } return err } // ChangeStatus enables or disables a target user. Disabling revokes the target's sessions. func (s *Service) ChangeStatus(ctx context.Context, targetID string, in StatusChangeInput, ip, ua string) error { if _, err := s.db(); err != nil { return err } actor, ok := tenant.UserFromContext(ctx) if !ok { return ErrForbidden } if actor.UserID == targetID { return ErrForbidden } if in.Status != "active" && in.Status != "disabled" { return ErrBadRequest } return s.deps.DB.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { t, e := readTarget(ctx, tx, targetID) if e != nil { return e } if !security.OutRanks(security.Role(actor.Role), security.Role(t.role)) { return ErrForbidden } if _, e := tx.Exec(ctx, `UPDATE users SET status = $1, updated_at = now() WHERE id = $2`, in.Status, t.id); e != nil { return e } action := "user.enabled" if in.Status == "disabled" { action = "user.disabled" if _, e := tx.Exec(ctx, `SELECT auth_revoke_user_sessions($1,$2)`, t.id, nil); e != nil { return e } } return s.auditTx(ctx, tx, actor, audit.Event{Action: action, EntityType: "user", EntityID: t.id.String(), IPAddress: ip, UserAgent: ua}) }) } // ResetMFA clears a target user's MFA (admin path), after proving same org and that the actor // outranks the target. The target must re enroll on next login. The target's sessions are revoked. func (s *Service) ResetMFA(ctx context.Context, targetID string, ip, ua string) error { if _, err := s.db(); err != nil { return err } actor, ok := tenant.UserFromContext(ctx) if !ok { return ErrForbidden } if actor.UserID == targetID { return ErrForbidden // no self reset; an admin only resets a user they outrank, matching ChangeRole and ChangeStatus } return s.deps.DB.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { t, e := readTarget(ctx, tx, targetID) if e != nil { return e } if !security.OutRanks(security.Role(actor.Role), security.Role(t.role)) { return ErrForbidden } if _, e := tx.Exec(ctx, `SELECT auth_mfa_reset($1)`, t.id); e != nil { return e } if _, e := tx.Exec(ctx, `SELECT auth_revoke_user_sessions($1,$2)`, t.id, nil); e != nil { return e } return s.auditTx(ctx, tx, actor, audit.Event{Action: "auth.mfa.reset", EntityType: "user", EntityID: t.id.String(), IPAddress: ip, UserAgent: ua}) }) } // SessionInfo returns the SPA view of the current authenticated session. func (s *Service) SessionInfo(ctx context.Context) (SessionInfo, error) { if _, err := s.db(); err != nil { return SessionInfo{}, err } p, ok := tenant.UserFromContext(ctx) if !ok { return SessionInfo{}, ErrForbidden } org, _ := tenant.OrgFromContext(ctx) info := SessionInfo{UserID: p.UserID, OrgID: org, Role: p.Role, Capabilities: security.CapabilitiesOf(security.Role(p.Role))} err := s.deps.DB.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { if e := tx.QueryRow(ctx, `SELECT mfa_enrolled FROM users WHERE id = $1`, mustUUID(p.UserID)).Scan(&info.MFAEnrolled); e != nil { return e } tools, e := readTools(ctx, tx, mustUUID(p.UserID)) if e != nil { return e } info.Tools = tools return nil }) return info, err } // readTools returns one user's granted tool set, normalized. It returns an EMPTY slice rather than nil // when a user has no grants, so the JSON is [] and the interface iterates without a null check, and so // the fail closed default reads as "entitled to nothing" rather than as missing data. func readTools(ctx context.Context, tx pgx.Tx, userID uuid.UUID) ([]string, error) { rows, err := tx.Query(ctx, `SELECT tool FROM user_tool_access WHERE user_id = $1 ORDER BY tool`, userID) if err != nil { return nil, err } defer rows.Close() var raw []string for rows.Next() { var t string if e := rows.Scan(&t); e != nil { return nil, e } raw = append(raw, t) } if e := rows.Err(); e != nil { return nil, e } return security.ToolsOf(raw), nil } // SetUserTools replaces a user's tool entitlement wholesale. // // Replace rather than add and remove: the caller sends the set they intend, and the result is exactly // that set. An incremental API invites a client that has drifted from the server to grant a tool it // believed was already absent, and leaves no single request that expresses "these four and no others". // // Authority is checked twice for different reasons. CanGrantTools bounds what may be GRANTED: the actor // must outrank the target and may only confer tools they hold themselves, so nobody widens their own // access through an account they control. Revocation is bounded only by outranking, because removing // access is the safe direction and an admin must be able to strip a tool they do not personally hold. // // It revokes the target's sessions, so a narrowed entitlement takes effect at once rather than at the // next session resolve. func (s *Service) SetUserTools(ctx context.Context, targetID string, tools []string, ip, ua string) error { if _, err := s.db(); err != nil { return err } actor, ok := tenant.UserFromContext(ctx) if !ok { return ErrForbidden } if actor.UserID == targetID { // Same rule as roles: nobody edits their own access, so no single account can widen itself. return ErrForbidden } want := security.ToolsOf(tools) var denied *denial err := s.deps.DB.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { d, e := s.setUserToolsTx(ctx, tx, actor, targetID, want, ip, ua) denied = d return e }) // Written after the transaction has rolled back, so the denial survives the refusal that caused it. if denied != nil { s.recordDenial(ctx, actor, *denied, ip, ua) } return err } // setUserToolsTx is the in transaction half of SetUserTools. // It returns a non nil denial when the actor was refused, so the caller can record it AFTER the rollback. func (s *Service) setUserToolsTx(ctx context.Context, tx pgx.Tx, actor tenant.Principal, targetID string, want []string, ip, ua string) (*denial, error) { t, e := readTarget(ctx, tx, targetID) if e != nil { return nil, e } actorTools, e := readTools(ctx, tx, mustUUID(actor.UserID)) if e != nil { return nil, e } current, e := readTools(ctx, tx, t.id) if e != nil { return nil, e } // Only the tools being ADDED are subject to the possession rule; the ones being removed are not. var adding []string for _, w := range want { if !security.HasTool(current, security.Tool(w)) { adding = append(adding, w) } } if !security.CanGrantTools(security.Role(actor.Role), security.Role(t.role), actorTools, adding) { return &denial{entityType: "user", entityID: t.id.String(), reason: "tool grant above authority"}, ErrForbidden } if _, e := tx.Exec(ctx, `DELETE FROM user_tool_access WHERE user_id = $1`, t.id); e != nil { return nil, e } for _, w := range want { if _, e := tx.Exec(ctx, `INSERT INTO user_tool_access (organization_id, user_id, tool, granted_by) VALUES ((SELECT organization_id FROM users WHERE id = $1), $1, $2, $3)`, t.id, w, mustUUID(actor.UserID)); e != nil { return nil, e } } // Revoke the target's sessions, exactly as ChangeRole, ChangeStatus and ResetMFA do. Entitlement // rides on the principal resolved with the session, so without this a narrowing edit would not bite // until the target's session was resolved again, and the account would keep reaching a tool that had // just been taken away. Every other privilege reducing path already pays this cost; this was the one // that did not, which made it the way to keep access after it was removed. // // It runs on a widening edit too. Distinguishing the two would mean deciding which direction an // arbitrary set replacement moves in, and a stale session that keeps the OLD narrower set is a // support ticket while a stale session that keeps the old WIDER set is the vulnerability. Costing a // re login on the rare grant is the cheaper mistake. if _, e := tx.Exec(ctx, `SELECT auth_revoke_user_sessions($1,$2)`, t.id, nil); e != nil { return nil, e } return nil, s.auditTx(ctx, tx, actor, audit.Event{Action: "auth.tools.change", EntityType: "user", EntityID: t.id.String(), IPAddress: ip, UserAgent: ua, OldValues: toolsJSON(current), NewValues: toolsJSON(want)}) } // toolsJSON renders a tool set for the audit trail. Entitlement changes are access control events, so the // before and after are both recorded rather than only the new value. func toolsJSON(tools []string) []byte { b, err := json.Marshal(map[string]any{"tools": tools}) if err != nil { return nil } return b } // ListUsers returns the org's users (RLS scoped). Gated on RequireRole(admin) at the route. func (s *Service) ListUsers(ctx context.Context) ([]UserSummary, error) { if _, err := s.db(); err != nil { return nil, err } var out []UserSummary err := s.deps.DB.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { rows, e := tx.Query(ctx, // Tool grants are aggregated in the same query rather than fetched per user, so the People screen // costs one round trip regardless of headcount instead of one per row. `SELECT u.id, u.email, u.full_name, u.role, u.status, u.mfa_enrolled, to_char(u.last_login_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), COALESCE(ARRAY(SELECT a.tool FROM user_tool_access a WHERE a.user_id = u.id ORDER BY a.tool), ARRAY[]::text[]) FROM users u ORDER BY u.created_at DESC, u.id DESC LIMIT 500`) if e != nil { return e } defer rows.Close() for rows.Next() { var u UserSummary var id uuid.UUID var tools []string if e := rows.Scan(&id, &u.Email, &u.FullName, &u.Role, &u.Status, &u.MFAEnrolled, &u.LastLoginAt, &tools); e != nil { return e } u.ID = id.String() u.Tools = security.ToolsOf(tools) out = append(out, u) } return rows.Err() }) return out, err } // auditSessionRejected records a probing or stale token attempt against a non empty session cookie. It // returns the write error so the caller can surface it; the request is denied regardless, but the // audit failure must not vanish silently (the module's fail closed audit contract). func (s *Service) auditSessionRejected(ctx context.Context, ip, ua string) error { return s.deps.Audit.System(ctx, audit.Event{Action: "auth.session.rejected", IPAddress: ip, UserAgent: ua}) } // readTarget reads a user inside the tenant tx. RLS scopes it to the actor's org, so a not found row // proves the target is in another org (or does not exist): the same org check is the read itself. func readTarget(ctx context.Context, tx pgx.Tx, id string) (targetUser, error) { uid := mustUUID(id) var t targetUser err := tx.QueryRow(ctx, `SELECT id, email, role, status FROM users WHERE id = $1`, uid).Scan(&t.id, &t.email, &t.role, &t.status) if errors.Is(err, pgx.ErrNoRows) { return targetUser{}, ErrNotFound } return t, err } func (s *Service) auditTx(ctx context.Context, tx pgx.Tx, actor tenant.Principal, e audit.Event) error { id := mustUUID(actor.UserID) e.ActorUserID = &id return s.deps.Audit.OnTx(ctx, tx, e) } func isUniqueViolation(err error) bool { var pgErr *pgconn.PgError return errors.As(err, &pgErr) && pgErr.Code == "23505" } // denial carries a refused authorization decision OUT of a transaction that is about to roll back. // // The three privileged paths below all wrote their authz.deny row with auditTx on the request's own // transaction and then returned ErrForbidden from the same closure. RunInTenantTx rolls back on any // error from fn, so the audit row went with it: every one of those denials left no trace, while the // caller still received a clean 403. A repeated privilege escalation attempt, which is exactly the // signal a reviewer looks for, was therefore invisible in the one place it should have been loudest. // // The record cannot simply be moved earlier, because the decision is only known once the target has been // read, and it cannot be written on a nested transaction without holding the outer one open across a // second write. So the denial is captured as data, the outer transaction rolls back as it should, and // the record is written afterwards on its own transaction. type denial struct { entityType string entityID string reason string } // recordDenial writes a captured denial on a FRESH transaction, after the caller's transaction has // already rolled back. // // Its own failure is deliberately swallowed rather than surfaced. The request is being refused either // way, and turning a failed audit write into a different error for the caller would tell them something // about the platform's internal state in response to an action they were not permitted to take. The // error is logged so the gap is visible to an operator rather than to the person who was refused. func (s *Service) recordDenial(ctx context.Context, actor tenant.Principal, d denial, ip, ua string) { err := s.deps.DB.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { return s.auditTx(ctx, tx, actor, audit.Event{ Action: "authz.deny", EntityType: d.entityType, EntityID: d.entityID, IPAddress: ip, UserAgent: ua, NewValues: reasonJSON(d.reason)}) }) if err != nil { slog.Error("auth: authz denial audit write failed", "error", err, "entity", d.entityID, "reason", d.reason) } }