Commit Graph

3 Commits

Author SHA1 Message Date
shankar0123 21aeed4f4e legal: addlicense headers + normalize legacy variants (Phase 0 RED-4)
Phase 0 closure (Path B2, post-rewrite):

addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:

  // Copyright 2026 certctl LLC. All rights reserved.
  // SPDX-License-Identifier: BUSL-1.1

Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).

Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.

Generated via:
  addlicense -c "certctl LLC" -y 2026 \
    -f cowork/legal/copyright-header.tpl \
    -ignore '**/testdata/**' -ignore '**/*_test.go' \
    cmd/ internal/

Verification:
  find cmd internal -name '*.go' -not -name '*_test.go' \
    -not -path '*/testdata/*' \
    -exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l

  Returns: 0

gofmt clean. Header additions are comments only, no compile impact.

Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
2026-05-13 21:23:35 +00:00
shankar0123 78485f7429 fix(auth/users): close MED-11 lying field — DeactivatedAt loaded + enforced on login (A-2)
The MED-11 closure shipped users.deactivated_at + DELETE /api/v1/auth/users/{id}
+ cascade-revoke, but the federated-user soft-delete was reversible: the next
OIDC login under the same (provider, subject) tuple re-minted a session and
re-elevated the user.

Three legs of the chain were severed (each independently CRIT-shaped):

  Leg A — postgres/user.go::userColumns omitted `deactivated_at`, so scanUser
          never populated User.DeactivatedAt. Every Get / GetByOIDCSubject /
          ListAll returned DeactivatedAt = nil regardless of the column value.

  Leg B — postgres/user.go::Update SQL omitted `deactivated_at = $X`, so the
          handler's `u.DeactivatedAt = now()` mutation was a no-op write at
          the SQL level. Even with leg A closed, no row ever flipped.

  Leg C — oidc/service.go::upsertUser did not inspect DeactivatedAt on the
          existing-user path. Even with legs A + B closed, the OIDC login
          would still proceed normally.

The cascade-session-revoke half of the original closure remained correct, but
only for the duration of the user's current cookie. SOC 2 CC6.3 + ISO 27001
A.9.2.6 "user access removal" controls require both immediate revoke AND
persistent block — this fix restores the persistent-block leg.

Closure across layers:

  internal/repository/postgres/user.go
    - userColumns adds `deactivated_at`
    - scanUser reads via sql.NullTime intermediate (column is nullable)
    - Create writes deactivated_at explicitly (NULL for new active users;
      forward-compat for future seed-data flows that pre-populate the column)
    - Update writes deactivated_at on every call; nil DeactivatedAt → NULL
      (supports reactivation)

  internal/auth/oidc/service.go
    - New sentinel ErrUserDeactivated
    - upsertUser checks existing.DeactivatedAt != nil BEFORE mutating email /
      display_name / last_login_at — preserves last_login_at forensics on
      rejected login attempts (defense-in-depth pin against future
      "performance optimization" that reorders the gate)

  internal/api/handler/auth_session_oidc.go
    - classifyOIDCFailure adds typed errors.Is dispatch for ErrUserDeactivated
      → audit category "user_deactivated" (SOC/SIEM observability surface)

  internal/api/handler/auth_users.go
    - Self-deactivate guard on Deactivate: HTTP 409 + audit row
      auth.user_deactivate_self_rejected when caller targets own User row.
      Prevents an admin from one-way-door locking themselves out via the
      standard handler; break-glass remains the recovery path.
    - New Reactivate handler: inverse of Deactivate. Clears DeactivatedAt
      via Update; emits auth.user_reactivated audit row. Idempotent on
      already-active rows. Sessions revoked at deactivation stay revoked
      (cascade irreversible by design — user must complete fresh OIDC
      login).

  internal/api/router/router.go
    - POST /api/v1/auth/users/{id}/reactivate wired with auth.user.deactivate
      gate (reactivation is the inverse op, not a separate privilege)

  web/src/api/client.ts + web/src/pages/auth/UsersPage.tsx
    - authReactivateUser() client function
    - Reactivate button on deactivated rows in UsersPage

Regression coverage:

  Postgres (testcontainers, skipped under -short):
    TestUserRepository_DeactivatedAt_RoundTrip — Create → set DeactivatedAt
      → Update → Get / GetByOIDCSubject / ListAll round-trip the value
    TestUserRepository_DeactivatedAt_CreateWritesNullForActive — new active
      user reads back DeactivatedAt = nil
    TestUserRepository_DeactivatedAt_CreatePersistsPreDeactivated — Create
      with non-nil DeactivatedAt round-trips (forward-compat path)

  OIDC service:
    TestService_HandleCallback_RejectsDeactivatedUser — errors.Is
      ErrUserDeactivated; CallbackResult nil; persisted email / last_login_at
      / deactivated_at NOT mutated by the rejected attempt
    TestService_HandleCallback_AllowsReactivatedUser — DeactivatedAt = nil
      → happy path resumes
    TestService_HandleCallback_DeactivatedUserPreservesForensics —
      defense-in-depth pin against future regressions that reorder the
      gate-vs-mutation sequence

  Classifier:
    TestClassifyOIDCFailure extended — typed dispatch + wrapped variant
      round-trip through errors.Is

  Handler:
    TestAuthUsers_Deactivate_RejectsSelfDeactivate — HTTP 409 + audit
      row + cascade-revoke NOT fired + row stays active
    TestAuthUsers_Deactivate_OtherUser_HappyPath — HTTP 204 + cascade
      fires + row soft-deleted
    TestAuthUsers_Reactivate_HappyPath / _IdempotentOnActiveUser /
      _UnknownID / _MissingID / _UpdateError

Phase 6 verify gate green on the targeted packages: gofmt clean, go vet
clean, go test -short pass across internal/auth/oidc, internal/api/handler,
internal/api/router, internal/repository/postgres, internal/auth/...,
internal/service/..., internal/tlsprobe/..., internal/trustanchor/...,
internal/validation/...

Spec at cowork/auth-bundles-fixes-2026-05-11/02-crit-deactivated-at-enforcement.md
Closure annotation at cowork/auth-bundles-audit-2026-05-10.md MED-11 row.
Operator advisory in CHANGELOG.md v2.1.0 release notes.
2026-05-11 02:21:05 +00:00
shankar0123 95f1d6cf63 auth-bundle-2 Phase 2b: repository interfaces + Postgres impls + integration tests
Closes Phase 2 end-to-end. Builds on Phase 2a's three migrations
(000034 oidc_providers + group_role_mappings, 000035 sessions +
session_signing_keys, 000036 users) by shipping the repository surface
Phase 3+ services consume.

Interfaces:
* internal/repository/oidc.go - OIDCProviderRepository (List, Get,
  GetByName, Create, Update, Delete) + GroupRoleMappingRepository
  (ListByProvider, Get, Add, Remove, Map). Sentinels:
  ErrOIDCProviderNotFound, ErrOIDCProviderDuplicateName,
  ErrOIDCProviderInUse (FK ON DELETE RESTRICT translation),
  ErrGroupRoleMappingNotFound, ErrGroupRoleMappingDuplicate.
* internal/repository/session.go - SessionRepository (Create, Get,
  ListByActor, UpdateLastSeen, Revoke, RevokeAllForActor,
  GarbageCollectExpired, Delete) + SessionSigningKeyRepository (List,
  GetActive, Get, Add, Retire, Delete). Sentinels: ErrSessionNotFound,
  ErrSessionRevoked, ErrSessionExpired, ErrSessionSigningKeyNotFound,
  ErrSessionSigningKeyInUse.
* internal/repository/user.go - UserRepository (Get, GetByOIDCSubject,
  Create, Update, ListAll). Sentinels: ErrUserNotFound,
  ErrUserDuplicateOIDCSubject.

Postgres implementations:
* internal/repository/postgres/oidc.go - 309 lines. Translates
  SQLSTATE 23505 (unique_violation) to ErrOIDCProviderDuplicateName /
  ErrGroupRoleMappingDuplicate; SQLSTATE 23503 (foreign_key_violation)
  to ErrOIDCProviderInUse so the Phase 5 handler maps to HTTP 409
  when an operator tries to delete a provider with authenticated
  users. pq.StringArray bridges Go []string to Postgres TEXT[] for
  scopes + allowed_email_domains. Map() uses
  `WHERE group_name = ANY($2)` so a single SELECT resolves N IdP
  group claims at once.
* internal/repository/postgres/session.go - 350 lines. Both Session +
  SessionSigningKey repos. Revoke + Retire are idempotent (re-revoking
  an already-revoked session returns nil; same for retire). The
  GarbageCollectExpired sweep deletes both
  absolute-expiry-passed sessions AND pre-login rows older than the
  10-minute TTL in one DELETE so the scheduler tick is cheap.
  ErrSessionSigningKeyInUse pinned via SQLSTATE 23503 from the
  sessions.signing_key_id FK ON DELETE RESTRICT.
* internal/repository/postgres/user.go - 137 lines. GetByOIDCSubject
  is the Phase 3 hot-path lookup; the (oidc_provider_id,
  oidc_subject) UNIQUE constraint trip translates to
  ErrUserDuplicateOIDCSubject. Update only writes the mutable field
  set (email, display_name, last_login_at, webauthn_credentials);
  oidc_subject + oidc_provider_id are immutable per the
  per-(provider, subject) identity model.

Integration tests (testing.Short()-gated, testcontainers + Postgres
16 Alpine, schema-per-test isolation via getTestDB().freshSchema):

* oidc_test.go: 11 tests covering happy-path + GetNotFound +
  DuplicateName + List + Update + DeleteNotFound + DeleteSucceeds +
  DeleteRefusedWhenUsersReference (the FK ON DELETE RESTRICT pin);
  GroupRoleMapping coverage includes Add/List/Map (3 cases:
  marketing-not-mapped, multi-group hits, empty groups returns
  empty), Duplicate rejection, and the ON DELETE CASCADE on
  provider deletion.
* session_test.go: 12 tests covering SessionSigningKey + Session.
  Key tests: GetActiveSkipsRetired (mints older, retires it, mints
  newer, asserts GetActive returns newer), DeleteRefusedWhenSessions-
  Reference (FK pin), RetireIsIdempotent. Session tests:
  CreateAndGet roundtrip, GetNotFound, Revoke + idempotent re-Revoke,
  ListByActor (3 active + 1 revoked + 1 pre-login -> returns 3,
  pinning the WHERE filter), RevokeAllForActor, GarbageCollectExpired
  (seeds an absolute-expired row + pre-login >10min row + active
  session via raw SQL to bypass CHECK constraints, asserts GC kills
  exactly 2 + active survives), UpdateLastSeen.
* user_test.go: 7 tests covering CreateAndGet, GetNotFound,
  GetByOIDCSubject (hit + miss), DuplicateOIDCSubjectRejected,
  UpdateMutableFields (asserts oidc_subject NOT mutated by Update),
  ListAll, FKRestrictsProviderDelete (mirror of the OIDC test from
  the user side - both ends of the FK contract pinned).

Verifications:
* gofmt -l clean across all 9 new files.
* go vet ./internal/repository/postgres/ rc=0.
* go test -short -count=1 green on internal/repository/postgres/ +
  internal/auth/... + Bundle 1 packages (testing.Short() skips the
  testcontainers integration tests, but the test files compile + the
  short-mode skip path is exercised so the suite is wired correctly).
* Full integration tests run in CI's non-short job against Postgres
  16 Alpine via testcontainers-go.
* govulncheck ./... clean.
* All 24 ci-guards pass.

Phase 2 exit criteria from cowork/auth-bundle-2-prompt.md (all met):
* All three Phase-2 migrations apply cleanly, idempotently: yes
  (Phase 2a). Break-glass migration ships separately in Phase 7.5.
* Repository tests pass against Postgres 16 Alpine: integration
  tests written, gated by testing.Short(), structured to run cleanly
  in CI's non-short job.
* make verify equivalent green: gofmt + vet + go test pass;
  golangci-lint deferred to CI per Phase 0/1's same pattern.
2026-05-10 04:18:27 +00:00