Files
certctl/internal/cli/auth.go
T
shankar0123 b169f258de auth-bundle-1 Phase 4 + 5: RBAC HTTP API + CLI surface
Phase 4 (HTTP API):

* internal/api/handler/auth.go: AuthHandler with 12 endpoints under /api/v1/auth/* — ListRoles, GetRole, CreateRole, UpdateRole, DeleteRole, ListPermissions, AddRolePermission, RemoveRolePermission, AssignRoleToKey, RevokeRoleFromKey, Me. callerFromRequest builds an authsvc.Caller from the Phase 3 ActorIDKey/ActorTypeKey/TenantIDKey context values. writeAuthError translates service + repository sentinels into HTTP status codes (401/403/404/409/400/500). 14 handler tests with in-memory fakes pin the HTTP shape + error mapping.

* internal/api/router/router.go: HandlerRegistry gains an Auth field; 11 new routes registered. openapi_parity_test SpecParityExceptions extended with the new auth routes (OpenAPI YAML schema land in a Phase 4 follow-up commit so the schema review is its own atomic change; the route shape is fully documented inline via the Go type definitions until then).

* cmd/server/main.go: wires the postgres auth repos (RoleRepository, PermissionRepository, ActorRoleRepository) + the Authorizer + RoleService/PermissionService/ActorRoleService into the new AuthHandler. Adds authPermissionCheckerAdapter to bridge the typed-string Authorizer signature to the auth.PermissionChecker interface (avoids an internal/auth → internal/service/auth import cycle).

Phase 5 (CLI):

* cmd/cli/main.go: adds 'auth' command dispatch with subcommands roles/permissions/keys/me.

* internal/cli/auth.go: AuthMe, AuthListRoles, AuthGetRole, AuthListPermissions, AuthAssignRoleToKey, AuthRevokeRoleFromKey methods on Client. Mirrors the Phase 4 HTTP surface.

Phase 3.5 (handler IsAdmin → middleware-wrapped RequirePermission) DEFERRED. Honest reasoning:

(1) The 5 admin handlers (bulk_revocation, admin_crl_cache, admin_scep_intune, admin_est, intermediate_ca) currently gate via auth.IsAdmin checks INSIDE the handler bodies. Converting cleanly requires moving the gate to the router (auth.RequirePermission middleware wrap) AND removing the in-handler check AND rewriting the existing 3-test triplets per handler (M-008 pinned: _NonAdmin_Returns403 / _AdminExplicitFalse_Returns403 / _AdminPermitted_ForwardsActor) because the existing tests call the handler function directly, bypassing middleware. After conversion, those tests would pass without 403'ing because the gate moved away — the test invariants need to flow through a router-level integration setup instead.

(2) Picking the right permission per handler is a security-review-worthy decision. Using existing operator-class perms (cert.revoke, issuer.edit) widens access from admin-only to operator-class; adding new admin-only perms (cert.bulk_revoke, crl.admin, scep.admin, est.admin, ca.hierarchy.manage) requires a migration 000030 plus a coordinated catalogue update in internal/domain/auth/validate.go. Both options are defensible but warrant a focused commit, not a 5-handler sweep mixed in with the API + CLI work.

(3) The conversion can be done now without functional regressions IF we leave the in-handler IsAdmin checks in place AND add middleware wraps as defense-in-depth — but that's the worst of both worlds (legacy gate still blocks non-admin operators, defeating the point of RBAC; new gate adds runtime cost with no semantic change). A clean conversion needs the in-handler check removed.

Concrete plan for Phase 3.5 (separate commit, next session): (a) add new admin-only perms via migration 000030 OR document the widening to operator-class; (b) wrap each of the 5 admin routes with auth.RequirePermission(checker, perm, nil) in router.go; (c) remove auth.IsAdmin checks from the 5 handler bodies; (d) move the M-008 _NonAdmin/_AdminExplicitFalse tests to router-level integration tests, keep _AdminPermitted as a direct handler test for actor-passthrough; (e) update m008_admin_gate_test.go registry to track auth.RequirePermission middleware wraps in router.go instead of auth.IsAdmin call sites in handler files.

Verifications: go vet ./... clean; gofmt clean across all touched files; go test -short -count=1 across internal/auth, internal/service/auth, internal/api/handler, internal/api/router, internal/cli, cmd/server, cmd/cli all green (one transient too-many-open-files retry on internal/cli + internal/api/router; second run clean).

Branch: dev/auth-bundle-1. Commit chain: 99a012e (Phase 0 extract) -> 19497ee (Phase 1 schema + repo) -> bd54d5f (Phase 2 service) -> d473398 (Phase 3 primitive) -> THIS (Phase 4 + 5).
2026-05-09 16:43:48 +00:00

254 lines
7.2 KiB
Go

package cli
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
)
// =============================================================================
// CLI auth subcommands. Bundle 1 Phase 5 mirrors the /api/v1/auth/*
// surface introduced in Phase 4. Read operations + key-role assignment +
// the /me identity check; mutating role lifecycle (create / update /
// delete) is a Phase 5.5 follow-up that adds the cobra-style flag
// parsing for description / name fields.
// =============================================================================
// authMeResponse mirrors handler.meResponse without importing the
// handler package (would couple CLI build to the server tree).
type authMeResponse struct {
ActorID string `json:"actor_id"`
ActorType string `json:"actor_type"`
TenantID string `json:"tenant_id"`
Admin bool `json:"admin"`
Roles []string `json:"roles"`
EffectivePermissions []struct {
Permission string `json:"permission"`
ScopeType string `json:"scope_type"`
ScopeID *string `json:"scope_id,omitempty"`
} `json:"effective_permissions"`
}
// AuthMe prints the current actor's identity + permissions. Useful for
// debugging RBAC config: confirms which actor the API key resolves to,
// which roles it holds, and the effective permission set.
func (c *Client) AuthMe() error {
body, err := c.doGET("/api/v1/auth/me")
if err != nil {
return err
}
if c.format == "json" {
fmt.Println(string(body))
return nil
}
var me authMeResponse
if err := json.Unmarshal(body, &me); err != nil {
return fmt.Errorf("decode /auth/me: %w", err)
}
fmt.Printf("Actor: %s (%s)\n", me.ActorID, me.ActorType)
fmt.Printf("Tenant: %s\n", me.TenantID)
fmt.Printf("Admin: %t\n", me.Admin)
fmt.Printf("Roles: %s\n", strings.Join(me.Roles, ", "))
fmt.Printf("Effective permissions:\n")
for _, p := range me.EffectivePermissions {
scope := p.ScopeType
if p.ScopeID != nil {
scope = fmt.Sprintf("%s:%s", p.ScopeType, *p.ScopeID)
}
fmt.Printf(" %s @ %s\n", p.Permission, scope)
}
return nil
}
// AuthListRoles prints all roles in the tenant.
func (c *Client) AuthListRoles() error {
body, err := c.doGET("/api/v1/auth/roles")
if err != nil {
return err
}
if c.format == "json" {
fmt.Println(string(body))
return nil
}
var resp struct {
Roles []struct {
ID, Name, Description string
TenantID string `json:"tenant_id"`
} `json:"roles"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("decode roles list: %w", err)
}
fmt.Printf("%-15s %-15s %s\n", "ID", "NAME", "DESCRIPTION")
for _, r := range resp.Roles {
fmt.Printf("%-15s %-15s %s\n", r.ID, r.Name, r.Description)
}
return nil
}
// AuthGetRole prints a single role + its permission grants.
func (c *Client) AuthGetRole(id string) error {
body, err := c.doGET("/api/v1/auth/roles/" + id)
if err != nil {
return err
}
if c.format == "json" {
fmt.Println(string(body))
return nil
}
var resp struct {
Role struct {
ID, Name, Description string
}
Permissions []struct {
PermissionID string `json:"permission_id"`
ScopeType string `json:"scope_type"`
ScopeID *string `json:"scope_id,omitempty"`
}
}
if err := json.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("decode role: %w", err)
}
fmt.Printf("ID: %s\n", resp.Role.ID)
fmt.Printf("Name: %s\n", resp.Role.Name)
fmt.Printf("Description: %s\n", resp.Role.Description)
fmt.Printf("Permissions (%d):\n", len(resp.Permissions))
for _, p := range resp.Permissions {
scope := p.ScopeType
if p.ScopeID != nil {
scope = fmt.Sprintf("%s:%s", p.ScopeType, *p.ScopeID)
}
fmt.Printf(" %s @ %s\n", p.PermissionID, scope)
}
return nil
}
// AuthListPermissions prints the canonical permission catalogue.
func (c *Client) AuthListPermissions() error {
body, err := c.doGET("/api/v1/auth/permissions")
if err != nil {
return err
}
if c.format == "json" {
fmt.Println(string(body))
return nil
}
var resp struct {
Permissions []struct {
ID, Name, Namespace string
} `json:"permissions"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("decode permissions: %w", err)
}
fmt.Printf("%-25s %s\n", "PERMISSION", "NAMESPACE")
for _, p := range resp.Permissions {
fmt.Printf("%-25s %s\n", p.Name, p.Namespace)
}
return nil
}
// AuthAssignRoleToKey grants a role to an API-key-named actor. The
// caller's key must hold auth.role.assign globally; service-layer
// returns 403 otherwise.
func (c *Client) AuthAssignRoleToKey(keyID, roleID string) error {
body, err := json.Marshal(map[string]string{"role_id": roleID})
if err != nil {
return err
}
if _, err := c.doPOST("/api/v1/auth/keys/"+keyID+"/roles", body); err != nil {
return err
}
fmt.Printf("granted %s to %s\n", roleID, keyID)
return nil
}
// AuthRevokeRoleFromKey revokes a role from an API-key-named actor.
// Service-layer rejects revocations against the reserved demo-anon
// actor with 409; CLI surfaces that as a non-zero exit.
func (c *Client) AuthRevokeRoleFromKey(keyID, roleID string) error {
if err := c.doDELETE("/api/v1/auth/keys/" + keyID + "/roles/" + roleID); err != nil {
return err
}
fmt.Printf("revoked %s from %s\n", roleID, keyID)
return nil
}
// =============================================================================
// HTTP helpers — minimal wrappers around the underlying http.Client used
// elsewhere in the package. Mirror the pattern from est.go (same
// authentication + TLS + error-handling shape).
// =============================================================================
func (c *Client) doGET(path string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, c.baseURL+path, nil)
if err != nil {
return nil, err
}
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
return c.doRaw(req)
}
func (c *Client) doPOST(path string, body []byte) ([]byte, error) {
req, err := http.NewRequest(http.MethodPost, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
return c.doRaw(req)
}
func (c *Client) doDELETE(path string) error {
req, err := http.NewRequest(http.MethodDelete, c.baseURL+path, nil)
if err != nil {
return err
}
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
_, err = c.doRaw(req)
return err
}
func (c *Client) doRaw(req *http.Request) ([]byte, error) {
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := readAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
return body, nil
}
// readAll wraps io.ReadAll without pulling another import; defined as a
// thin function so we can swap to a bounded reader later if needed.
func readAll(r interface{ Read(p []byte) (int, error) }) ([]byte, error) {
var buf []byte
tmp := make([]byte, 4096)
for {
n, err := r.Read(tmp)
if n > 0 {
buf = append(buf, tmp[:n]...)
}
if err != nil {
if err.Error() == "EOF" {
return buf, nil
}
return buf, err
}
}
}