Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/auth/service_users.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 |
| Branch | feature/aioe-upgrade |
| Path or archive member | internal/modules/auth/service_users.go |
| Original SHA-256 | 4fab02ceeb8b8c8e9d1f20380cd390348b0346d1448e594f1c6722321fc24b06 |
| Git blob SHA | ad7d7a1948e2ade35cfc0627cc14102934bc6841 |
| Reading-copy SHA-256 | 9a6fc82b19868e107ce20033c188eed3b0282fb73bf789265bab3a3008f1ada4 |
Recovered source transcript
Download complete source text ↓650 lines · 27.8 KB
Credential-bearing and unrelated personal details were removed from this reading copy. The source identity and original checksum remain recorded.
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)
}
}