mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:51:30 +00:00
21aeed4f4e
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
205 lines
7.0 KiB
Go
205 lines
7.0 KiB
Go
// Copyright 2026 certctl LLC. All rights reserved.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
//
|
|
// Audit 2026-05-11 A-8 — demo-mode residual-grants detector. Closes the
|
|
// deferred Phase 2 leg of HIGH-12 (cowork/auth-bundles-fixes-2026-05-10/
|
|
// 11-high-12-demo-mode-guard.md). The HIGH-12 closure (`b81588e`) added
|
|
// the fail-closed bind-address guard at config.Validate; the deferred
|
|
// leg here adds a startup-time WARN (or strict refuse-startup) when
|
|
// `actor-demo-anon` has live role grants under a non-`none` auth type.
|
|
//
|
|
// Why this matters: migration 000029 unconditionally seeds the
|
|
// `ar-demo-anon-admin` row granting r-admin to actor-demo-anon. The
|
|
// row is dormant under auth_type=api-key|oidc (the middleware chain
|
|
// never injects the synthetic actor as the request principal), but
|
|
// it represents a security debt: any future regression in the
|
|
// middleware chain (a misrouted CORS preflight, a fallback in a new
|
|
// auth-exempt route) that resolves to actor-demo-anon would re-elevate
|
|
// to admin. The canonical acquisition-readiness narrative — "we have
|
|
// an RBAC primitive with no synthetic-admin fallback" — requires this
|
|
// row to be either gone or explicitly acknowledged.
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/config"
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
|
"github.com/certctl-io/certctl/internal/service"
|
|
)
|
|
|
|
// preflightDemoModeResidual runs after the DB connection is open and
|
|
// the audit service is constructed, before the HTTPS listener starts.
|
|
//
|
|
// Behaviour:
|
|
// - cfg.Auth.Type == "none" (demo mode): no-op. The residual IS the
|
|
// runtime state at that auth type.
|
|
// - cfg.Auth.Type != "none" + no residue: returns nil silently.
|
|
// - cfg.Auth.Type != "none" + residue + strict=false: emits a WARN
|
|
// log AND an `auth.demo_residual_grants_detected` audit row
|
|
// listing the grant IDs, then returns nil.
|
|
// - cfg.Auth.Type != "none" + residue + strict=true: emits the same
|
|
// WARN + audit, then returns a non-nil error so the caller can
|
|
// refuse startup.
|
|
//
|
|
// The audit row's actor is `system` / ActorTypeSystem; category is
|
|
// EventCategoryAuth so audit consumers filtering on auth events see it.
|
|
func preflightDemoModeResidual(
|
|
ctx context.Context,
|
|
cfg *config.Config,
|
|
db *sql.DB,
|
|
audit *service.AuditService,
|
|
logger *slog.Logger,
|
|
) error {
|
|
if cfg.Auth.Type == "none" {
|
|
// Demo mode itself. The residual is the runtime state at
|
|
// this auth type, so warning about it would be noise.
|
|
return nil
|
|
}
|
|
|
|
residue, err := queryDemoAnonResidue(ctx, db)
|
|
if err != nil {
|
|
return fmt.Errorf("preflight demo-mode residual: %w", err)
|
|
}
|
|
if len(residue) == 0 {
|
|
return nil
|
|
}
|
|
|
|
formatted := make([]string, 0, len(residue))
|
|
for _, r := range residue {
|
|
formatted = append(formatted, r.String())
|
|
}
|
|
|
|
msg := fmt.Sprintf(
|
|
"production startup warning: actor-demo-anon has %d residual role grant(s) "+
|
|
"from the migration 000029 baseline or a prior demo-mode run: %s. "+
|
|
"These grants are DORMANT at the current auth_type (%s) but represent a "+
|
|
"security debt — any future regression that resolves an unauthenticated "+
|
|
"request to actor-demo-anon would re-elevate to admin. Clean up via "+
|
|
"POST /api/v1/auth/demo-residual/cleanup (requires auth.role.assign) or "+
|
|
"`DELETE FROM actor_roles WHERE actor_id = 'actor-demo-anon';`. Set "+
|
|
"CERTCTL_DEMO_MODE_RESIDUAL_STRICT=true to refuse startup until cleanup.",
|
|
len(residue), strings.Join(formatted, "; "), cfg.Auth.Type,
|
|
)
|
|
if logger != nil {
|
|
logger.Warn(msg, "auth_type", cfg.Auth.Type, "residue_count", len(residue))
|
|
} else {
|
|
slog.Warn(msg)
|
|
}
|
|
|
|
if audit != nil {
|
|
details := map[string]interface{}{
|
|
"auth_type": cfg.Auth.Type,
|
|
"residue_count": len(residue),
|
|
"residue": formatted,
|
|
}
|
|
if err := audit.RecordEventWithCategory(
|
|
ctx, "system", domain.ActorTypeSystem,
|
|
"auth.demo_residual_grants_detected",
|
|
domain.EventCategoryAuth,
|
|
"actor_roles", authdomain.DemoAnonActorID,
|
|
details,
|
|
); err != nil {
|
|
// Don't fail startup over an audit-write error; just log.
|
|
if logger != nil {
|
|
logger.Warn("preflight demo-mode residual: audit record failed", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if cfg.Auth.DemoModeResidualStrict {
|
|
return fmt.Errorf(
|
|
"startup refused: actor-demo-anon has %d residual role grant(s) and "+
|
|
"CERTCTL_DEMO_MODE_RESIDUAL_STRICT=true. Remove the rows before restarting",
|
|
len(residue),
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// demoAnonResidueRow describes a single live actor_roles row whose
|
|
// actor_id matches the synthetic demo-anon ID.
|
|
type demoAnonResidueRow struct {
|
|
RoleID string
|
|
ScopeType string
|
|
ScopeID string
|
|
GrantedAt time.Time
|
|
}
|
|
|
|
// String renders one row as `role@scope (granted ts)`. Used both in
|
|
// the WARN log message and in the audit row's residue list.
|
|
func (r demoAnonResidueRow) String() string {
|
|
scope := r.ScopeType
|
|
if r.ScopeID != "" {
|
|
scope = fmt.Sprintf("%s/%s", r.ScopeType, r.ScopeID)
|
|
}
|
|
return fmt.Sprintf("%s@%s (granted %s)", r.RoleID, scope, r.GrantedAt.UTC().Format(time.RFC3339))
|
|
}
|
|
|
|
// queryDemoAnonResidue runs the canonical query for the residue
|
|
// detector + the cleanup endpoint. Kept in one place so the two
|
|
// surfaces can't drift on which rows count as "live".
|
|
//
|
|
// "Live" = not expired. Rows with expires_at <= NOW() are treated
|
|
// as already gone (they have no effect even if the actor were to be
|
|
// injected as the principal).
|
|
func queryDemoAnonResidue(ctx context.Context, db *sql.DB) ([]demoAnonResidueRow, error) {
|
|
if db == nil {
|
|
return nil, errors.New("db is nil")
|
|
}
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT role_id, scope_type, COALESCE(scope_id, '') AS scope_id, granted_at
|
|
FROM actor_roles
|
|
WHERE actor_id = $1
|
|
AND (expires_at IS NULL OR expires_at > NOW())
|
|
ORDER BY granted_at ASC, role_id ASC, scope_type ASC, COALESCE(scope_id, '') ASC
|
|
`, authdomain.DemoAnonActorID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query actor_roles: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []demoAnonResidueRow
|
|
for rows.Next() {
|
|
var r demoAnonResidueRow
|
|
if err := rows.Scan(&r.RoleID, &r.ScopeType, &r.ScopeID, &r.GrantedAt); err != nil {
|
|
return nil, fmt.Errorf("scan actor_roles row: %w", err)
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate actor_roles rows: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// deleteDemoAnonResidue removes every live actor_roles row for the
|
|
// synthetic demo-anon actor. Returns the count removed. Used by the
|
|
// POST /api/v1/auth/demo-residual/cleanup handler. Idempotent — a
|
|
// follow-up call returns 0.
|
|
func deleteDemoAnonResidue(ctx context.Context, db *sql.DB) (int64, error) {
|
|
if db == nil {
|
|
return 0, errors.New("db is nil")
|
|
}
|
|
res, err := db.ExecContext(ctx, `
|
|
DELETE FROM actor_roles
|
|
WHERE actor_id = $1
|
|
`, authdomain.DemoAnonActorID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("delete actor_roles: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("rows affected: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|