mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +00:00
bd54d5f7fa
Bundle 1 / Phase 2: ships PermissionService, RoleService, ActorRoleService, and the Authorizer primitive that Phase 3 RequirePermission middleware calls on every gated request.
Authorizer.CheckPermission semantics: a grant matches when (a) the permission name equals the requested permission AND (b) the grant is global-scoped OR the grant scope_type+scope_id exactly match the request. Global beats specific; per-resource grants widen the effective set rather than shadowing global. Hot-path query is one ActorRoleRepository.EffectivePermissions JOIN call (already shipped in Phase 1) plus an in-memory walk; Phase 12 will add benchmarks + caching if the JOIN cost shows up at scale.
Privilege-escalation guard: ActorRoleService.Grant and Revoke require the caller to hold auth.role.assign globally. Without it, ErrSelfRoleAssignment. System callers (AsSystemCaller()) bypass the check; bootstrap, migrations, scheduler-initiated grants use this path. Reserved actor actor-demo-anon is rejected on Grant + Revoke so the demo path stays alive even after a misclick (ErrAuthReservedActor).
Caller abstraction: every service entry point takes *Caller (ActorID, ActorType, TenantID, IsSystem). CallerFromContext is a stub returning ErrUnauthenticated; Phase 3 wires the middleware-context bridge that fills the Caller from request context. The contract is pinned by TestCallerFromContext_Phase2ReturnsUnauthenticated so the Phase 3 upgrade is observable.
Audit recording: every mutating service operation calls AuditService.RecordEvent. Bundle 1 Phase 8 adds the event_category column + parameter and back-fills 'auth' for these calls; until then the rows go in with the default category.
Test coverage: in-memory fakeRoleRepo / fakePermissionRepo / fakeActorRoleRepo / fakeAudit pin the privilege-escalation invariants (ErrUnauthenticated for nil caller, ErrForbidden for missing perm, ErrInvalidPermission for non-canonical permission name, ErrSelfRoleAssignment for Grant without auth.role.assign, ErrAuthReservedActor for actor-demo-anon mutations, system-caller bypass) without requiring testcontainers. Phase 12 will add live-Postgres integration coverage.
Branch: dev/auth-bundle-1. Phase 1 was 19497ee (RBAC schema + repo). Phase 3 (middleware integration) is the next commit on this branch.
41 lines
1.4 KiB
Go
41 lines
1.4 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
|
|
authdomain "github.com/certctl-io/certctl/internal/domain/auth"
|
|
"github.com/certctl-io/certctl/internal/repository"
|
|
)
|
|
|
|
// PermissionService exposes the canonical permission catalogue. It is
|
|
// thin (read-only) because Bundle 1 ships permissions as immutable
|
|
// migration-seeded rows; callers cannot define new permissions at
|
|
// runtime. Bundle 2 extends the catalogue with auth.session.* and
|
|
// auth.oidc.* permissions via a new migration.
|
|
type PermissionService struct {
|
|
repo repository.PermissionRepository
|
|
}
|
|
|
|
// NewPermissionService constructs a PermissionService.
|
|
func NewPermissionService(repo repository.PermissionRepository) *PermissionService {
|
|
return &PermissionService{repo: repo}
|
|
}
|
|
|
|
// List returns every permission in the catalogue.
|
|
func (s *PermissionService) List(ctx context.Context) ([]*authdomain.Permission, error) {
|
|
return s.repo.List(ctx)
|
|
}
|
|
|
|
// GetByName returns the permission with the given canonical name, or
|
|
// repository.ErrAuthNotFound if no row matches.
|
|
func (s *PermissionService) GetByName(ctx context.Context, name string) (*authdomain.Permission, error) {
|
|
return s.repo.GetByName(ctx, name)
|
|
}
|
|
|
|
// IsRegistered reports whether the named permission exists in the
|
|
// canonical catalogue. Cheap in-memory lookup; used by RoleService
|
|
// before issuing a DB write to fail-fast on typos.
|
|
func (s *PermissionService) IsRegistered(name string) bool {
|
|
return s.repo.IsCanonical(name)
|
|
}
|