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 | 09015496d565a172bcaa15cfe9a8e4b072b13df0 |
| Branch | workstream-b-screenmatch |
| Path or archive member | internal/modules/auth/service_users.go |
| Original SHA-256 | 4486d5ecffda4424e025b64c40cd939f986a1a229fd7975b1fb3c334017f2c4b |
| Git blob SHA | 1c1b5863892d7d0250cd2ca15eaab1a24c05ddd1 |
| Reading-copy SHA-256 | f56ddd0bea5e2bc33a1ddd7e84fb4f7868f196300f5c62484cd0041ad351a168 |
Every recorded occurrence
These 57 records contain identical original source bytes. Their paths and revisions remain part of the archive.
| Original record | Branch or archive | Revision / identifier | Original location |
|---|---|---|---|
| Janady13/missionmatch / internal/modules/auth/service_users.go | workstream-b-screenmatch | 09015496d565a172bcaa15cfe9a8e4b072b13df0 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | master | 1e9249840a4302ec82e123b9c9cb6f75badea3e4 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | workstream-d-missionmatch | 3be46ba89edb737182075a2b320509b42dc916f8 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | m5-price-to-win | 41000ad30fff4129fdaf5ec84e15ba14c27d4890 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | workstream-e-dataflow-deploy | 433815a042f9741411e60fa553fe72143e9b10db | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | w3-ai-staffing-recommendations | 43d56387270ef4da4cabd794d97ca6a49909bc7d | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | ws3-clearance | 4b8e7ce594c068621867f9c9deed0ec64ae6c553 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | collision-deconfliction | 4ee5a09972608ed89f8377c6665542d5f450f0a6 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | workstream-c-slatematch | 5cd60c918301b46ecd6ed3e03e20c395da0062e2 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | ai-intelligence-layer | 5d5ddff70393a197c5f54a45280bb5dd42b24755 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | ws7-federal-signal | 6a9f6ce9cfb13345ffb47770d4960e7c2d9c17c2 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | ws6-retrieval | 6c56e29231ec05735927fa265c97d517499dfeaf | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | per-tenant-routing | 9bde0f9a74b89d01980fa658e5253acda7528d0c | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | ai-brain-live | aac32fc49c878cc5bca099459307611562d0bac1 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | graphrag-foundation | b0ddeb9f633fd138d09309d1a17b7d13bc3a67f7 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | ws1-ontology-sync | b596f449a32a1016fbd403c36505cc00a1ac9ee6 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | time-to-fill-survival | b66e1e505863d4f17741a4b9ede73509e45bd6c6 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | ruthless-debug-sweep | b7d742f8f032984be3817efffa3eb8b13b651c1f | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | mag-substrate | b82c7100a492dec67dfd1eda6b3f53def859be35 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | capture-readiness | bae6af7cb2997e819174a9e9cfff5e16067a1975 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | deploy-and-ops | bdacd9d0b4d5998d25fc74bf4007f4c3ec538c7a | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | ws8-teaching-loop | c823f0fb6bb2aba0f39001eb6de96f4000d9321f | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | phase2-eval-fairness | d361af254701975fe239927656ababb56374655e | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | phase2-connectors | e038d4c687aec16f8bd9bc4d907eea14d91f8704 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | workstream-a-shared-core | e0de03ac3a1ec826bd15d0e048e3b02baa23eeff | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | ws2-alias-relationship | e4dada4601c6eb101830f9d223a9655dfa120616 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | presentation-readiness | eb574acaa29d71ce4d0f852572e87c275c79fdb7 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | ws9-bench-ingestion | f585a9ef32fa554663ce1fba9a414a54b6e49cf0 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | vendor-intelligence | fe9796a19c261951d3f2565206c0c1b5ac294465 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | per-tenant-routing | 9bde0f9a74b89d01980fa658e5253acda7528d0c | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | collision-deconfliction | 4ee5a09972608ed89f8377c6665542d5f450f0a6 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | ws6-retrieval | 6c56e29231ec05735927fa265c97d517499dfeaf | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | workstream-a-shared-core | e0de03ac3a1ec826bd15d0e048e3b02baa23eeff | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | m5-price-to-win | 41000ad30fff4129fdaf5ec84e15ba14c27d4890 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | workstream-c-slatematch | 5cd60c918301b46ecd6ed3e03e20c395da0062e2 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | ws1-ontology-sync | b596f449a32a1016fbd403c36505cc00a1ac9ee6 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | master | 1e9249840a4302ec82e123b9c9cb6f75badea3e4 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | ws8-teaching-loop | c823f0fb6bb2aba0f39001eb6de96f4000d9321f | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | w3-ai-staffing-recommendations | 43d56387270ef4da4cabd794d97ca6a49909bc7d | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | mag-substrate | b82c7100a492dec67dfd1eda6b3f53def859be35 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | ws3-clearance | 4b8e7ce594c068621867f9c9deed0ec64ae6c553 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | phase2-eval-fairness | d361af254701975fe239927656ababb56374655e | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | workstream-e-dataflow-deploy | 433815a042f9741411e60fa553fe72143e9b10db | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | ruthless-debug-sweep | b7d742f8f032984be3817efffa3eb8b13b651c1f | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | time-to-fill-survival | b66e1e505863d4f17741a4b9ede73509e45bd6c6 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | ai-intelligence-layer | 5d5ddff70393a197c5f54a45280bb5dd42b24755 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | graphrag-foundation | b0ddeb9f633fd138d09309d1a17b7d13bc3a67f7 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | deploy-and-ops | bdacd9d0b4d5998d25fc74bf4007f4c3ec538c7a | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | ws9-bench-ingestion | f585a9ef32fa554663ce1fba9a414a54b6e49cf0 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | ws2-alias-relationship | e4dada4601c6eb101830f9d223a9655dfa120616 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | presentation-readiness | eb574acaa29d71ce4d0f852572e87c275c79fdb7 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | workstream-b-screenmatch | 09015496d565a172bcaa15cfe9a8e4b072b13df0 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | ws7-federal-signal | 6a9f6ce9cfb13345ffb47770d4960e7c2d9c17c2 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | ai-brain-live | aac32fc49c878cc5bca099459307611562d0bac1 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | workstream-d-missionmatch | 3be46ba89edb737182075a2b320509b42dc916f8 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | phase2-connectors | e038d4c687aec16f8bd9bc4d907eea14d91f8704 | Open original |
| Janady13/missionmatch / internal/modules/auth/service_users.go | capture-readiness | bae6af7cb2997e819174a9e9cfff5e16067a1975 | Open original |
Recovered source transcript
Download complete source text ↓335 lines · 12.7 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"
"errors"
"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.
_ = 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")})
})
return InviteResult{}, ErrForbidden
}
token, tokenHash, err := security.NewSessionToken()
if err != nil {
return InviteResult{}, err
}
var newUserID uuid.UUID
err = s.deps.DB.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
e := tx.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) {
return ErrEmailInUse
}
if e != nil {
return e
}
if _, e := tx.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 e
}
return s.auditTx(ctx, tx, actor, audit.Event{Action: "user.invite.created", EntityType: "user", EntityID: newUserID.String(), IPAddress: ip, UserAgent: ua})
})
if err != nil {
return InviteResult{}, err
}
return InviteResult{Token: token, UserID: newUserID.String()}, nil
}
// 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
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
}
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 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
}
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)) ||
!security.OutRanks(security.Role(actor.Role), security.Role(in.Role)) {
_ = s.auditTx(ctx, tx, actor, audit.Event{Action: "authz.deny", EntityType: "user", EntityID: t.id.String(), IPAddress: ip, UserAgent: ua, NewValues: reasonJSON("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})
})
}
// 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}
err := s.deps.DB.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
return tx.QueryRow(ctx, `SELECT mfa_enrolled FROM users WHERE id = $1`, mustUUID(p.UserID)).Scan(&info.MFAEnrolled)
})
return info, err
}
// 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, `SELECT id, email, full_name, role, status, mfa_enrolled FROM users ORDER BY created_at DESC, id DESC LIMIT 500`)
if e != nil {
return e
}
defer rows.Close()
for rows.Next() {
var u UserSummary
var id uuid.UUID
if e := rows.Scan(&id, &u.Email, &u.FullName, &u.Role, &u.Status, &u.MFAEnrolled); e != nil {
return e
}
u.ID = id.String()
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"
}