Files
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

171 lines
7.5 KiB
Go

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
package repository
import (
"context"
"errors"
"time"
oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain"
)
// Sentinel errors for the OIDC repositories. Postgres implementations
// translate SQLSTATE codes into these so handler / service code can
// branch via errors.Is.
var (
// ErrOIDCProviderNotFound: Get / GetByName returned no row. HTTP 404.
ErrOIDCProviderNotFound = errors.New("oidc: provider not found")
// ErrOIDCProviderDuplicateName: Create tripped the (tenant_id, name)
// UNIQUE constraint. HTTP 409.
ErrOIDCProviderDuplicateName = errors.New("oidc: provider with this name already exists in tenant")
// ErrOIDCProviderInUse: Delete failed because at least one users row
// references the provider via oidc_provider_id (FK ON DELETE
// RESTRICT). HTTP 409.
ErrOIDCProviderInUse = errors.New("oidc: provider has authenticated users; revoke all sessions before delete")
// ErrGroupRoleMappingNotFound: Get returned no row. HTTP 404.
ErrGroupRoleMappingNotFound = errors.New("oidc: group-role mapping not found")
// ErrGroupRoleMappingDuplicate: Add tripped the
// (provider_id, group_name, role_id) UNIQUE constraint. HTTP 409.
ErrGroupRoleMappingDuplicate = errors.New("oidc: group-role mapping already exists")
)
// OIDCProviderRepository wraps the oidc_providers table. Phase 3's
// OIDCService consumes List + Get to look up the IdP for token
// validation; the GUI / CLI wire Create / Update / Delete behind
// auth.oidc.* permission gates per Phase 5.
type OIDCProviderRepository interface {
// List returns every configured provider in the tenant. Order:
// created_at ASC for stable GUI rendering.
List(ctx context.Context, tenantID string) ([]*oidcdomain.OIDCProvider, error)
// Get returns one provider by id. ErrOIDCProviderNotFound on miss.
Get(ctx context.Context, id string) (*oidcdomain.OIDCProvider, error)
// GetByName returns one provider by (tenant_id, name).
// ErrOIDCProviderNotFound on miss.
GetByName(ctx context.Context, tenantID, name string) (*oidcdomain.OIDCProvider, error)
// Create persists a new provider. Caller MUST have already called
// p.Validate() and encrypted the client_secret_encrypted byte
// stream via internal/crypto/encryption.go. Returns
// ErrOIDCProviderDuplicateName when the (tenant_id, name) UNIQUE
// constraint fires.
Create(ctx context.Context, p *oidcdomain.OIDCProvider) error
// Update writes the full mutable field set back to the row.
// Immutable fields (id, tenant_id, created_at) are read-only;
// updated_at is set to NOW() by the implementation.
Update(ctx context.Context, p *oidcdomain.OIDCProvider) error
// Delete removes a provider by id. Returns ErrOIDCProviderInUse
// when at least one users row references this provider (FK ON
// DELETE RESTRICT). Phase 5's handler maps to HTTP 409.
Delete(ctx context.Context, id string) error
}
// GroupRoleMappingRepository wraps the group_role_mappings table.
// Phase 3's OIDCService.HandleCallback uses Map() to translate IdP
// group claims into role IDs; the GUI / CLI wire ListByProvider /
// Add / Remove for operator configuration.
type GroupRoleMappingRepository interface {
// ListByProvider returns every mapping for the named provider.
// Order: group_name ASC for stable GUI rendering.
ListByProvider(ctx context.Context, providerID string) ([]*oidcdomain.GroupRoleMapping, error)
// Get returns one mapping by id. ErrGroupRoleMappingNotFound on miss.
Get(ctx context.Context, id string) (*oidcdomain.GroupRoleMapping, error)
// Add persists a new mapping. Caller MUST have called m.Validate().
// Returns ErrGroupRoleMappingDuplicate when the
// (provider_id, group_name, role_id) UNIQUE constraint fires.
Add(ctx context.Context, m *oidcdomain.GroupRoleMapping) error
// Remove deletes a mapping by id.
Remove(ctx context.Context, id string) error
// Map resolves an IdP-supplied list of group names against the
// provider's mappings. Returns the deduplicated set of role IDs
// the user should hold. Empty result means the user matches no
// mapping (Phase 3 fail-closed: no session minted, audit row
// `auth.oidc_login_unmapped_groups`).
Map(ctx context.Context, providerID string, groupNames []string) ([]string, error)
}
// =============================================================================
// PreLoginRepository — Bundle 2 Phase 5.
//
// Holds short-lived rows that carry OIDC state + nonce + PKCE verifier
// across the IdP redirect. Distinct from the sessions table because
// sessions doesn't carry OIDC-specific columns. 10-minute absolute TTL
// at the schema layer (oidc_pre_login_sessions.absolute_expires_at);
// the GC sweep deletes expired rows.
//
// Cookie wire format `v1.<pl-id>.<sk-id>.<HMAC-SHA256>` matches the
// post-login session cookie format exactly; signing-key id is the
// active SessionSigningKey at handshake time.
// =============================================================================
// PreLoginSession is the row shape for oidc_pre_login_sessions. Held
// here (not in oidc/domain) because it's a Phase-5 storage primitive,
// not a domain concept the wider service layer reasons about.
type PreLoginSession struct {
ID string // prefix `pl-`
TenantID string
SigningKeyID string // FK to session_signing_keys.id
OIDCProviderID string // FK to oidc_providers.id
State string
Nonce string
PKCEVerifier string
CreatedAt time.Time
AbsoluteExpiresAt time.Time
// Audit 2026-05-10 MED-16 — UA / IP binding (RFC 9700 §4.7.1).
// Persisted at /auth/oidc/login; compared on consume to defeat
// pre-login cookie theft. Either column may be empty for in-flight
// rows from a pre-deploy code path during a rolling deploy; the
// consume-side check only enforces when BOTH the row AND the
// incoming request carry non-empty values.
ClientIP string
UserAgent string
}
// Sentinel errors for PreLoginRepository.
var (
// ErrPreLoginNotFound: LookupAndConsume found no row with the
// supplied id. The handler maps to HTTP 400 (replay or forgery).
ErrPreLoginNotFound = errors.New("oidc: pre-login session not found or already consumed")
// ErrPreLoginExpired: the row was found but absolute_expires_at is
// in the past. The handler maps to HTTP 400. The row is also
// deleted (the consume side of LookupAndConsume).
ErrPreLoginExpired = errors.New("oidc: pre-login session expired (10-minute TTL exceeded)")
)
// PreLoginRepository wraps the oidc_pre_login_sessions table.
type PreLoginRepository interface {
// Create persists a new pre-login row. Caller MUST have already
// generated the random id, state, nonce, and PKCE verifier;
// CreatedAt + AbsoluteExpiresAt default to NOW() and NOW()+10min
// at the schema layer when zero.
Create(ctx context.Context, p *PreLoginSession) error
// LookupAndConsume reads the row by id AND deletes it atomically
// (single-use). Returns ErrPreLoginNotFound if no row matches OR
// if the row was already consumed by a concurrent caller.
// Returns ErrPreLoginExpired if the row was found but expired
// (the row is still deleted in this case so retries don't
// re-trigger the expiry check).
LookupAndConsume(ctx context.Context, id string) (*PreLoginSession, error)
// GarbageCollectExpired deletes pre-login rows whose
// absolute_expires_at is in the past. Returns the count deleted.
// Wired into the same scheduler sweep as expired post-login sessions.
GarbageCollectExpired(ctx context.Context) (int, error)
}