mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 15:28:54 +00:00
f5ba17114d
Audit 2026-05-10 HIGH-6 partial closure (silence leg). The audit
identified two distinct gaps in the auth surface's audit-emit pattern:
(1) silence — `_ = audit.RecordEventWithCategory(...)` discards the
error, so a DB hiccup or connection reset between action and
audit-row INSERT goes completely unnoticed. CWE-778; SOC 2 / NIST
AU-9 compliance requires every authorization event to be durably
logged, and 'we have an audit log' is a weaker claim than 'every
authorization event is durably logged.'
(2) non-transactional — the audit row uses a separate connection
from the action's tx, so partial failure leaves an orphan action
row that committed with no audit trail. Decision 8 of the
auth-bundles-index requires action + audit row atomic.
This commit closes leg (1) fully across all six audit-emit call sites
in the auth surface:
- internal/service/auth/actor_role_service.go::recordAudit
- internal/service/auth/role_service.go::recordAudit
- internal/auth/bootstrap/service.go::ValidateAndMint
- internal/auth/breakglass/service.go::recordAudit
- internal/auth/session/service.go::recordAudit
- internal/api/handler/auth_session_oidc.go::recordAudit
- internal/service/profile.go::Update (Phase 9 approval-bypass)
Each `_ = ...` swallow is replaced with:
if err := audit.RecordEventWithCategory(...); err != nil {
slog.WarnContext(ctx, '<surface> audit write failed (action
committed; audit row may be missing)',
'action', action, 'actor_id', actor, 'resource_id', resource,
'err', err)
}
Operators monitoring audit-write failures now see structured WARN
logs with action + actor + resource attribution; missing audit rows
can be cross-referenced against monitoring without manual SELECT-from-
audit-table.
Infrastructure for leg (2) (transactional commit) is also landed in
this commit:
- service.AuditService.RecordEventWithCategoryWithTx (new method;
accepts repository.Querier from postgres.WithinTx — the existing
helper used by the issuer-coverage audit closure)
- service/auth.AuditService interface declares the new method
- test stub fakeAudit.RecordEventWithCategoryWithTx satisfies the
extended interface
The eight per-path WithinTx-refactors documented in
cowork/auth-bundles-fixes-2026-05-10/10-high-6-atomic-audit-commit.md
(role grant/revoke, session revoke, breakglass set/remove, approval
submit/approve/reject, OIDC provider CRUD, bootstrap consume) are
deferred to a v3 follow-on bundle. Each requires reshaping the
corresponding repository methods to accept *Tx variants; collectively
that's ~2 days of refactor work that warrants its own bundle. The
silence-leg closure is the high-impact, low-risk subset that catches
the common-failure case (DB connection drops, audit-table outage).
Refs: cowork/auth-bundles-audit-2026-05-10.md HIGH-6
Spec: cowork/auth-bundles-fixes-2026-05-10/10-high-6-atomic-audit-commit.md
195 lines
7.2 KiB
Go
195 lines
7.2 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
|
"github.com/certctl-io/certctl/internal/repository"
|
|
)
|
|
|
|
// ActorRoleService grants / revokes roles to actors and exposes the
|
|
// effective-permissions query the Phase 3 middleware uses on the hot
|
|
// path.
|
|
type ActorRoleService struct {
|
|
repo repository.ActorRoleRepository
|
|
roleRepo repository.RoleRepository
|
|
authorizer *Authorizer
|
|
audit AuditService
|
|
}
|
|
|
|
// NewActorRoleService constructs an ActorRoleService.
|
|
func NewActorRoleService(
|
|
repo repository.ActorRoleRepository,
|
|
roleRepo repository.RoleRepository,
|
|
authorizer *Authorizer,
|
|
audit AuditService,
|
|
) *ActorRoleService {
|
|
return &ActorRoleService{
|
|
repo: repo,
|
|
roleRepo: roleRepo,
|
|
authorizer: authorizer,
|
|
audit: audit,
|
|
}
|
|
}
|
|
|
|
// Grant assigns a role to an actor. Privilege-escalation guard: the
|
|
// caller must hold `auth.role.assign` (globally). System callers
|
|
// bypass. Reserved actor `actor-demo-anon` is rejected.
|
|
func (s *ActorRoleService) Grant(ctx context.Context, caller *Caller, ar *authdomain.ActorRole) error {
|
|
if caller == nil {
|
|
return ErrUnauthenticated
|
|
}
|
|
if !caller.IsSystem {
|
|
ok, err := s.authorizer.HoldsAnyOf(ctx, caller.ActorID, authdomain.ActorTypeValue(caller.ActorType), s.tenantOf(caller), "auth.role.assign")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("%w: auth.role.assign required", ErrSelfRoleAssignment)
|
|
}
|
|
}
|
|
if ar.ActorID == authdomain.DemoAnonActorID {
|
|
return fmt.Errorf("%w: actor-demo-anon is reserved", repository.ErrAuthReservedActor)
|
|
}
|
|
if ar.TenantID == "" {
|
|
ar.TenantID = authdomain.DefaultTenantID
|
|
}
|
|
if err := s.repo.Grant(ctx, ar); err != nil {
|
|
return err
|
|
}
|
|
s.recordAudit(ctx, caller, "actor_role.grant", "actor_role", ar.ID, map[string]interface{}{
|
|
"actor_id": ar.ActorID,
|
|
"actor_type": string(ar.ActorType),
|
|
"role_id": ar.RoleID,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// Revoke removes a previously-granted role from an actor. Same
|
|
// privilege guard as Grant: caller needs `auth.role.assign` to mutate
|
|
// role membership. Reserved actor `actor-demo-anon` is rejected so the
|
|
// demo path stays alive even after a misclick.
|
|
func (s *ActorRoleService) Revoke(ctx context.Context, caller *Caller, actorID string, actorType domain.ActorType, roleID string) error {
|
|
if caller == nil {
|
|
return ErrUnauthenticated
|
|
}
|
|
if !caller.IsSystem {
|
|
ok, err := s.authorizer.HoldsAnyOf(ctx, caller.ActorID, authdomain.ActorTypeValue(caller.ActorType), s.tenantOf(caller), "auth.role.assign")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("%w: auth.role.assign required", ErrSelfRoleAssignment)
|
|
}
|
|
}
|
|
if actorID == authdomain.DemoAnonActorID {
|
|
return fmt.Errorf("%w: actor-demo-anon is reserved", repository.ErrAuthReservedActor)
|
|
}
|
|
tenantID := s.tenantOf(caller)
|
|
if err := s.repo.Revoke(ctx, actorID, authdomain.ActorTypeValue(actorType), roleID, tenantID); err != nil {
|
|
return err
|
|
}
|
|
s.recordAudit(ctx, caller, "actor_role.revoke", "actor_role", roleID, map[string]interface{}{
|
|
"actor_id": actorID,
|
|
"actor_type": string(actorType),
|
|
"role_id": roleID,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// ListForActor returns the roles held by the named actor.
|
|
func (s *ActorRoleService) ListForActor(ctx context.Context, caller *Caller, actorID string, actorType domain.ActorType) ([]*authdomain.ActorRole, error) {
|
|
if caller == nil {
|
|
return nil, ErrUnauthenticated
|
|
}
|
|
if !caller.IsSystem && caller.ActorID != actorID {
|
|
ok, err := s.authorizer.HoldsAnyOf(ctx, caller.ActorID, authdomain.ActorTypeValue(caller.ActorType), s.tenantOf(caller), "auth.role.list")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: auth.role.list required to view another actor's roles", ErrForbidden)
|
|
}
|
|
}
|
|
return s.repo.ListByActor(ctx, actorID, authdomain.ActorTypeValue(actorType), s.tenantOf(caller))
|
|
}
|
|
|
|
// EffectivePermissions returns the deduplicated (permission, scope)
|
|
// pairs granted to the actor across all roles. Phase 3 middleware
|
|
// (auth.RequirePermission) calls this on every gated request via the
|
|
// Authorizer; that hot path skips RBAC self-checks. The service-level
|
|
// method here is for handler / GUI callers (the /v1/auth/me endpoint).
|
|
func (s *ActorRoleService) EffectivePermissions(ctx context.Context, caller *Caller, actorID string, actorType domain.ActorType) ([]repository.EffectivePermission, error) {
|
|
if caller == nil {
|
|
return nil, ErrUnauthenticated
|
|
}
|
|
if !caller.IsSystem && caller.ActorID != actorID {
|
|
ok, err := s.authorizer.HoldsAnyOf(ctx, caller.ActorID, authdomain.ActorTypeValue(caller.ActorType), s.tenantOf(caller), "auth.role.list")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: auth.role.list required to view another actor's permissions", ErrForbidden)
|
|
}
|
|
}
|
|
return s.repo.EffectivePermissions(ctx, actorID, authdomain.ActorTypeValue(actorType), s.tenantOf(caller))
|
|
}
|
|
|
|
// ListKeys (Bundle 1 Phase 7) returns every actor in the tenant that
|
|
// holds at least one role grant. Permission `auth.role.list` is
|
|
// required (or the caller must be system). The CLI's `auth keys list`
|
|
// + scope-down helper consume this to enumerate the operator-key
|
|
// population without a separate /v1/auth/keys-by-name surface.
|
|
func (s *ActorRoleService) ListKeys(ctx context.Context, caller *Caller) ([]repository.ActorWithRoles, error) {
|
|
if caller == nil {
|
|
return nil, ErrUnauthenticated
|
|
}
|
|
if !caller.IsSystem {
|
|
ok, err := s.authorizer.HoldsAnyOf(ctx, caller.ActorID, authdomain.ActorTypeValue(caller.ActorType), s.tenantOf(caller), "auth.role.list")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: auth.role.list required to list keys", ErrForbidden)
|
|
}
|
|
}
|
|
return s.repo.ListDistinctActors(ctx, s.tenantOf(caller))
|
|
}
|
|
|
|
func (s *ActorRoleService) tenantOf(caller *Caller) string {
|
|
if caller != nil && caller.TenantID != "" {
|
|
return caller.TenantID
|
|
}
|
|
return authdomain.DefaultTenantID
|
|
}
|
|
|
|
func (s *ActorRoleService) recordAudit(ctx context.Context, caller *Caller, action, resourceType, resourceID string, details map[string]interface{}) {
|
|
if s.audit == nil || caller == nil {
|
|
return
|
|
}
|
|
// Bundle 1 Phase 8: every actor-role grant/revoke is an
|
|
// authentication / authorization event. The auditor role queries
|
|
// /v1/audit?category=auth to surface this slice without
|
|
// also pulling in cert.* events.
|
|
//
|
|
// Audit 2026-05-10 HIGH-6 partial closure: the audit emit is still
|
|
// best-effort relative to the action transaction (the transactional-
|
|
// leg WithinTx refactor is a v3 follow-on; see
|
|
// cowork/auth-bundles-fixes-2026-05-10/10-high-6-atomic-audit-commit.md).
|
|
// What this commit closes is the *silence* leg — swap the discarded
|
|
// `_ = ...` pattern for an explicit WARN log so a DB hiccup or
|
|
// connection reset between action and audit is observable to the
|
|
// operator instead of going unnoticed (CWE-778).
|
|
if err := s.audit.RecordEventWithCategory(ctx, caller.ActorID, caller.ActorType, action, domain.EventCategoryAuth, resourceType, resourceID, details); err != nil {
|
|
slog.WarnContext(ctx, "audit write failed (action committed; audit row may be missing)",
|
|
"action", action,
|
|
"resource_type", resourceType,
|
|
"resource_id", resourceID,
|
|
"actor_id", caller.ActorID,
|
|
"err", err)
|
|
}
|
|
}
|