package auth import ( "context" "database/sql" "errors" "fmt" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" ) // IdentitiesService implements Identities (and the routing.Resolver contract). type IdentitiesService struct { *Core } var ( _ Identities = (*IdentitiesService)(nil) ) // Mint implements Identities.Mint (SPEC-004 §5). // // A user has at most one non-revoked identity at a time, so Mint refuses if one // still exists — recovery goes through Rotate, which retires the old one first // (D-007). func (s *IdentitiesService) Mint(ctx context.Context, userID int64) (int64, string, error) { ok, err := s.userExists(userID) if err != nil { return 0, "", err } if !ok { return 0, "", fmt.Errorf("%w: user:%d", ErrUnknownUser, userID) } var live int if err := s.db.QueryRow( `SELECT count(*) FROM identities WHERE user_id = ? AND status <> 'revoked'`, userID, ).Scan(&live); err != nil { return 0, "", fmt.Errorf("auth: count live identities: %w", err) } if live > 0 { return 0, "", fmt.Errorf("%w: user:%d", ErrIdentityExists, userID) } guid, err := newGUID(s.db) if err != nil { return 0, "", err } res, err := s.db.Exec( `INSERT INTO identities (user_id, guid, status, created_at) VALUES (?, ?, ?, ?)`, userID, guid, string(StatusPendingEnrollment), store.FormatTime(s.clock()), ) if err != nil { return 0, "", fmt.Errorf("auth: insert identity: %w", err) } id, err := res.LastInsertId() if err != nil { return 0, "", fmt.Errorf("auth: identity id: %w", err) } // The ACTOR is the creator/approver and comes from the audit.Meta on ctx // (ruling R-4); the affected user is the SUBJECT. The event leaves // ActorUserID unset so audit.Merge fills it from the context. s.record(ctx, audit.Event{ Action: "identity.mint", Subject: fmt.Sprintf("user:%d", userID), Detail: fmt.Sprintf("identity:%d status=pending_enrollment", id), }) return id, guid, nil } // Activate implements Identities.Activate: pending_enrollment -> active. func (s *IdentitiesService) Activate(ctx context.Context, id int64) error { idn, err := s.identityByID(id) if err != nil { return err } if idn.Status != StatusPendingEnrollment { return fmt.Errorf("%w: identity:%d is %s", ErrIdentityNotPending, id, idn.Status) } now := store.FormatTime(s.clock()) if _, err := s.db.Exec( `UPDATE identities SET status = ?, enrolled_at = ? WHERE identity_id = ?`, string(StatusActive), now, id, ); err != nil { // The partial unique index rejects a second active identity (AC-1). return fmt.Errorf("auth: activate identity: %w", err) } // The actor is the context actor (R-4); the affected user is the subject. s.record(ctx, audit.Event{ Action: "identity.activate", Subject: fmt.Sprintf("user:%d", idn.UserID), Detail: fmt.Sprintf("identity:%d status=active", id), }) return nil } // Rotate implements Identities.Rotate (D-007, AC-5): revoke the current // identity AND all its sessions, then mint a fresh pending identity with a NEW // GUID. The old GUID is tombstoned forever. func (s *IdentitiesService) Rotate(ctx context.Context, userID int64) (string, error) { ok, err := s.userExists(userID) if err != nil { return "", err } if !ok { return "", fmt.Errorf("%w: user:%d", ErrUnknownUser, userID) } now := store.FormatTime(s.clock()) tx, err := s.db.Begin() if err != nil { return "", fmt.Errorf("auth: begin rotate: %w", err) } defer func() { _ = tx.Rollback() }() // Retire the current identity, if any. var oldID int64 err = tx.QueryRow( `SELECT identity_id FROM identities WHERE user_id = ? AND status <> 'revoked' ORDER BY identity_id DESC LIMIT 1`, userID, ).Scan(&oldID) switch { case errors.Is(err, sql.ErrNoRows): // nothing to revoke case err != nil: return "", fmt.Errorf("auth: find current identity: %w", err) default: if _, err := tx.Exec( `UPDATE identities SET status = ?, revoked_at = ? WHERE identity_id = ?`, string(StatusRevoked), now, oldID, ); err != nil { return "", fmt.Errorf("auth: revoke old identity: %w", err) } if _, err := tx.Exec( `UPDATE sessions SET revoked_at = ? WHERE identity_id = ? AND revoked_at IS NULL`, now, oldID, ); err != nil { return "", fmt.Errorf("auth: revoke old sessions: %w", err) } } // Mint the replacement. guid, err := newGUID(tx) if err != nil { return "", err } if _, err := tx.Exec( `INSERT INTO identities (user_id, guid, status, created_at) VALUES (?, ?, ?, ?)`, userID, guid, string(StatusPendingEnrollment), now, ); err != nil { return "", fmt.Errorf("auth: insert rotated identity: %w", err) } if err := tx.Commit(); err != nil { return "", fmt.Errorf("auth: commit rotate: %w", err) } s.record(ctx, audit.Event{ Action: "identity.rotate", Subject: fmt.Sprintf("user:%d", userID), Detail: "old identity revoked; new GUID minted", }) return guid, nil } // Revoke implements Identities.Revoke: revoke the identity and every session // it minted. func (s *IdentitiesService) Revoke(ctx context.Context, id int64) error { idn, err := s.identityByID(id) if err != nil { return err } if idn.Status == StatusRevoked { return nil // idempotent } now := store.FormatTime(s.clock()) tx, err := s.db.Begin() if err != nil { return fmt.Errorf("auth: begin revoke: %w", err) } defer func() { _ = tx.Rollback() }() if _, err := tx.Exec( `UPDATE identities SET status = ?, revoked_at = ? WHERE identity_id = ?`, string(StatusRevoked), now, id, ); err != nil { return fmt.Errorf("auth: revoke identity: %w", err) } if _, err := tx.Exec( `UPDATE sessions SET revoked_at = ? WHERE identity_id = ? AND revoked_at IS NULL`, now, id, ); err != nil { return fmt.Errorf("auth: revoke sessions: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("auth: commit revoke: %w", err) } s.record(ctx, audit.Event{ Action: "identity.revoke", Subject: fmt.Sprintf("user:%d", idn.UserID), Detail: fmt.Sprintf("identity:%d status=revoked", id), }) return nil } // ByGUID implements Identities.ByGUID. Unlike a revoked GUID — which is // tombstoned and looks like "not found" to the door — a revoked identity is // still returned here so callers can distinguish and audit it. // // It takes a context for symmetry with the rest of the service contract // (ruling R-1); the lookup itself is read-only. func (s *IdentitiesService) ByGUID(ctx context.Context, guid string) (Identity, error) { return s.byGUID(guid) } // byGUID is the context-free read behind ByGUID and the routing.Resolver // contract (which cannot take a context — it is part of the pure model, // SPEC-002 §3.1). func (s *IdentitiesService) byGUID(guid string) (Identity, error) { if guid == "" { return Identity{}, ErrIdentityNotFound } row := s.db.QueryRow(` SELECT identity_id, user_id, guid, status, created_at, enrolled_at, revoked_at FROM identities WHERE guid = ?`, guid) return scanIdentity(row) } // ByUserID returns the user's current (non-revoked) identity — the newest row // that is not a tombstone. It is the wizard's lookup (SPEC-005 §4): the step is // derived from the admin's identity status. Revoked rows stay as tombstones // (D-008) and are never selected. func (s *IdentitiesService) ByUserID(ctx context.Context, userID int64) (Identity, error) { row := s.db.QueryRow(` SELECT identity_id, user_id, guid, status, created_at, enrolled_at, revoked_at FROM identities WHERE user_id = ? AND status <> 'revoked' ORDER BY identity_id DESC LIMIT 1`, userID) return scanIdentity(row) } // GUID implements the routing.Resolver contract (SPEC-002 §3.1): it answers // found/enrolled for the pure model. A revoked (tombstoned) GUID is reported as // not found so the door returns 404 (SPEC-006 §3). // // It deliberately takes NO context: it is an implementation of the pure // model's injected interface and never writes audit. func (s *IdentitiesService) GUID(guid string) (found, enrolled bool) { idn, err := s.byGUID(guid) if err != nil { return false, false } switch idn.Status { case StatusActive: return true, true case StatusPendingEnrollment: return true, false default: // revoked return false, false } } // WithinEnrollmentWindow reports whether a pending identity is still inside its // bounded invite window (SPEC-004 §4, A1). A non-pending identity is false. func (s *IdentitiesService) WithinEnrollmentWindow(ctx context.Context, id int64) (bool, error) { idn, err := s.identityByID(id) if err != nil { return false, err } if idn.Status != StatusPendingEnrollment { return false, nil } return idn.CreatedAt.Add(s.enrollWindow).After(s.clock()), nil }