Files
certctl/internal/repository/postgres/user.go
T
shankar0123 c4ed3da30b fix(ci): Sprint 6 CI follow-up — staticcheck ST1021 + tenant-query baseline + skip inventory
Sprint 6 push (commits 43836ac + 663b14b) tripped three CI guards.
Fixing all three in this single follow-up — each is a small,
mechanical correction that doesn't change behavior:

1. staticcheck ST1021: AuditChainSnapshot doc comment was on the
   wrong type.

   internal/service/audit_chain_metric.go:91 had:
     // Snapshot returns the current counter state for the Prometheus
     // exposer. Reads use atomic loads — no mutex.
     type AuditChainSnapshot struct { ... }

   The comment described Snapshot() (the method on AuditChainCounter)
   but sat directly above the AuditChainSnapshot struct. staticcheck
   ST1021 requires exported-type comments to start with the type's
   name + optional leading article. Rewrote to lead with
   "AuditChainSnapshot is the point-in-time view ...".

2. multi-tenant-query-coverage: baseline drifted 31 → 32 because
   Sprint 6 COMP-002-RETENTION added UserRepository.ListDeactivatedBefore
   at internal/repository/postgres/user.go:191 — legitimately
   tenant-spanning by design.

   The retention policy is control-plane-wide (one
   CERTCTL_USER_RETENTION_WINDOW for the whole deployment, not
   per-tenant). The scheduler's userRetentionLoop walks every
   tenant's deactivated users on the same tick. A per-tenant
   tenant_id filter would require the scheduler to iterate every
   tenant — more code for equivalent semantics.

   Per the guard's own documentation (option b), legitimately
   tenant-spanning queries get an inline rationale comment + a
   baseline lift. Both delivered:
     - Inline comment block on the SELECT in user.go::ListDeactivatedBefore.
     - BASELINE_COUNT 31 → 32 in
       scripts/ci-guards/multi-tenant-query-coverage.sh, with the
       Sprint 6 rebase entry added to the rebase-history comment.

3. skip-inventory-drift: docs/testing/skip-inventory.md was stale.
   COMP-001-HASH added three new t.Skip sites in
   internal/repository/postgres/audit_chain_test.go (the three
   testing.Short() gates on the testcontainers integration tests).
   Re-ran ./scripts/skip-inventory.sh to regenerate the doc —
   totals went from 144 → 147 sites + 78 → 82 short-mode guards.

Verified locally:
  bash scripts/ci-guards/multi-tenant-query-coverage.sh      (clean)
  bash scripts/ci-guards/skip-inventory-drift.sh              (clean)
  go vet ./...                                                (clean)
  staticcheck ./internal/service/...                          (clean)

Closes the three Sprint 6 CI failures. The next CI run should
green out.
2026-05-16 06:24:09 +00:00

218 lines
7.3 KiB
Go

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/lib/pq"
userdomain "github.com/certctl-io/certctl/internal/auth/user/domain"
"github.com/certctl-io/certctl/internal/repository"
)
// UserRepository is the postgres implementation of
// repository.UserRepository (Auth Bundle 2 Phase 2).
type UserRepository struct {
db *sql.DB
}
// NewUserRepository constructs a UserRepository.
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{db: db}
}
// Audit 2026-05-11 A-2 — deactivated_at column added in migration
// 000045 (MED-11 foundation) but pre-fix never read here. The
// federated-user soft-delete flow at
// internal/api/handler/auth_users.go::Deactivate set the column on
// Update, but Get / GetByOIDCSubject / ListAll all returned User
// with zero-value DeactivatedAt regardless. The OIDC login path
// trusts the returned struct, so a deactivated user's next login
// re-elevated them. Adding the column to userColumns + scanUser
// closes the read leg; service.go's upsertUser closes the enforce leg.
const userColumns = `id, tenant_id, email, display_name, oidc_subject,
oidc_provider_id, last_login_at, webauthn_credentials,
created_at, updated_at, deactivated_at`
func scanUser(row interface{ Scan(...interface{}) error }) (*userdomain.User, error) {
var u userdomain.User
var deactivatedAt sql.NullTime
if err := row.Scan(
&u.ID, &u.TenantID, &u.Email, &u.DisplayName, &u.OIDCSubject,
&u.OIDCProviderID, &u.LastLoginAt, &u.WebAuthnCredentials,
&u.CreatedAt, &u.UpdatedAt, &deactivatedAt,
); err != nil {
return nil, err
}
if deactivatedAt.Valid {
t := deactivatedAt.Time
u.DeactivatedAt = &t
}
return &u, nil
}
// Get returns one user by id.
func (r *UserRepository) Get(ctx context.Context, id string) (*userdomain.User, error) {
row := r.db.QueryRowContext(ctx, `SELECT `+userColumns+` FROM users WHERE id = $1`, id)
u, err := scanUser(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, repository.ErrUserNotFound
}
return nil, fmt.Errorf("users get: %w", err)
}
return u, nil
}
// GetByOIDCSubject is the Phase 3 hot-path lookup at login time.
// Returns ErrUserNotFound if no row matches the (provider, subject)
// tuple — Phase 3's HandleCallback then creates the row via Create.
func (r *UserRepository) GetByOIDCSubject(ctx context.Context, providerID, subject string) (*userdomain.User, error) {
row := r.db.QueryRowContext(ctx, `
SELECT `+userColumns+`
FROM users
WHERE oidc_provider_id = $1 AND oidc_subject = $2`,
providerID, subject)
u, err := scanUser(row)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, repository.ErrUserNotFound
}
return nil, fmt.Errorf("users get_by_oidc_subject: %w", err)
}
return u, nil
}
// Create persists a new user. Translates SQLSTATE 23505 into
// ErrUserDuplicateOIDCSubject (the unique constraint on
// (oidc_provider_id, oidc_subject)).
//
// Audit 2026-05-11 A-2 — deactivated_at written explicitly. New rows
// pre-fix had deactivated_at NULL by schema default; the explicit
// write makes forward-compat with future seed-data paths that
// pre-populate the column (e.g. migration of an external user roster
// where some entries land deactivated). nil → NULL via sql.NullTime.
func (r *UserRepository) Create(ctx context.Context, u *userdomain.User) error {
var deactivatedAt sql.NullTime
if u.DeactivatedAt != nil {
deactivatedAt = sql.NullTime{Time: *u.DeactivatedAt, Valid: true}
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO users (
id, tenant_id, email, display_name, oidc_subject,
oidc_provider_id, last_login_at, webauthn_credentials,
deactivated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
u.ID, u.TenantID, u.Email, u.DisplayName, u.OIDCSubject,
u.OIDCProviderID, u.LastLoginAt, u.WebAuthnCredentials,
deactivatedAt)
if err != nil {
var pqErr *pq.Error
if errors.As(err, &pqErr) && pqErr.Code == "23505" {
return repository.ErrUserDuplicateOIDCSubject
}
return fmt.Errorf("users create: %w", err)
}
return nil
}
// Update writes the mutable fields (email, display_name, last_login_at,
// webauthn_credentials, deactivated_at) back to the row. Immutable:
// id, tenant_id, oidc_subject, oidc_provider_id, created_at.
// updated_at = NOW().
//
// Audit 2026-05-11 A-2 — deactivated_at is now in the mutable set so
// the federated-user soft-delete flow at
// internal/api/handler/auth_users.go::Deactivate persists. Pre-fix the
// Update SQL omitted it; the handler set u.DeactivatedAt = now on the
// in-memory struct, called Update, the SQL ignored the field, and the
// row was unchanged. nil DeactivatedAt → NULL (supports reactivation).
func (r *UserRepository) Update(ctx context.Context, u *userdomain.User) error {
var deactivatedAt sql.NullTime
if u.DeactivatedAt != nil {
deactivatedAt = sql.NullTime{Time: *u.DeactivatedAt, Valid: true}
}
res, err := r.db.ExecContext(ctx, `
UPDATE users SET
email = $2,
display_name = $3,
last_login_at = $4,
webauthn_credentials = $5,
deactivated_at = $6,
updated_at = NOW()
WHERE id = $1`,
u.ID, u.Email, u.DisplayName, u.LastLoginAt, u.WebAuthnCredentials, deactivatedAt)
if err != nil {
return fmt.Errorf("users update: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return repository.ErrUserNotFound
}
return nil
}
// ListAll returns every user in the tenant, ordered by created_at ASC.
func (r *UserRepository) ListAll(ctx context.Context, tenantID string) ([]*userdomain.User, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT `+userColumns+` FROM users WHERE tenant_id = $1 ORDER BY created_at ASC`,
tenantID)
if err != nil {
return nil, fmt.Errorf("users list_all: %w", err)
}
defer rows.Close()
var out []*userdomain.User
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, fmt.Errorf("users scan: %w", err)
}
out = append(out, u)
}
return out, rows.Err()
}
// ListDeactivatedBefore returns every user (across all tenants) whose
// deactivated_at is not NULL AND strictly before threshold. Sprint 6
// COMP-002-RETENTION — the userRetentionLoop in the scheduler walks
// this list per tick and calls UserRetentionService.DeleteUserPII on
// each. Cross-tenant on purpose: a single retention policy spans the
// whole control plane.
//
// multi-tenant-query-coverage carve-out: the SELECT below intentionally
// omits `tenant_id` because retention is a control-plane-wide policy
// (one CERTCTL_USER_RETENTION_WINDOW for the whole deployment, not
// per-tenant). Adding a `tenant_id = $N` filter would require the
// scheduler loop to iterate every tenant, which is more code for
// equivalent semantics. The guard's baseline counts this query.
func (r *UserRepository) ListDeactivatedBefore(ctx context.Context, threshold time.Time) ([]*userdomain.User, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT `+userColumns+`
FROM users
WHERE deactivated_at IS NOT NULL
AND deactivated_at < $1
ORDER BY deactivated_at ASC`,
threshold)
if err != nil {
return nil, fmt.Errorf("users list_deactivated_before: %w", err)
}
defer rows.Close()
var out []*userdomain.User
for rows.Next() {
u, err := scanUser(rows)
if err != nil {
return nil, fmt.Errorf("users scan: %w", err)
}
out = append(out, u)
}
return out, rows.Err()
}