Files
certctl/internal/service/auth/actor_role_service.go
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

215 lines
8.0 KiB
Go

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
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.
//
// Audit 2026-05-11 A-4 — opts narrows the revoke to a specific
// (scope_type, scope_id) variant. Zero value preserves the legacy
// "revoke all variants" behaviour. When opts.ScopeType is set the
// repository returns repository.ErrActorRoleNotFound if no row matches;
// the handler maps it to HTTP 404. The audit row records the scope so
// operators can distinguish "wide revoke" from "selective revoke" in
// the SIEM.
func (s *ActorRoleService) Revoke(ctx context.Context, caller *Caller, actorID string, actorType domain.ActorType, roleID string, opts repository.ActorRoleRevokeOptions) 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, opts); err != nil {
return err
}
details := map[string]interface{}{
"actor_id": actorID,
"actor_type": string(actorType),
"role_id": roleID,
}
if opts.ScopeType != "" {
details["scope_type"] = string(opts.ScopeType)
if opts.ScopeID != nil {
details["scope_id"] = *opts.ScopeID
}
} else {
details["scope"] = "all_variants"
}
s.recordAudit(ctx, caller, "actor_role.revoke", "actor_role", roleID, details)
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)
}
}