mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:11:38 +00:00
926bb4b301
Self-audit caught five real gaps in 3c605d5; this commit closes them. # Phase 8 — issuer/target audit rows now classified as 'config' The Phase 8 prompt explicitly required existing config-mutation calls (issuer config, target config, etc.) to write event_category=config. The3c605d5commit only migrated the auth service callers; the 6 issuer/target call-sites (internal/service/issuer.go: create/update/delete_issuer + internal/service/target.go: create/update/delete_target) still defaulted to cert_lifecycle. They now pass through RecordEventWithCategory(..., domain.EventCategoryConfig, ...) so auditors filtering /v1/audit?category=config see the slice the migration's docstring promised. # Auditor exit-criterion test Phase 8's exit criteria pin 'a user with the auditor role can list / export audit events but gets 403 on every other endpoint.' Bundle 1 unit invariants (auditor permission set, rbacGate behaviour) were in place but no end-to-end test walked the full set of admin perms with an auditor actor. internal/api/router/rbac_gate_integration_test.go gains TestRBACGate_AuditorRole_403sOnAdminRoutes (table-driven across all 5 admin perms — cert.bulk_revoke / crl.admin / scep.admin / est.admin / ca.hierarchy.manage) plus TestRBACGate_AuditorRole_PassesAuditReadGate (positive case for audit.read). # gofmt drift3c605d5left two cosmetic struct-field-alignment diffs in internal/cli/auth.go and internal/api/handler/audit_handler_test.go that gofmt -l flagged. CI's gofmt step would have failed; gofmt -w applied; gofmt -l now clean across the repo. # CHANGELOG path-prefix CHANGELOG.md v2.1.0 used '/v1/auth/bootstrap' shorthand in the operator-facing flow examples. The actual route is '/api/v1/auth/bootstrap'; an operator copy-pasting the curl would 404. All five hits replaced. Verifications: gofmt clean, go vet ./internal/service/ ./internal/api/router/ clean, go test -short -count=1 green across internal/service + internal/api/router, including the 6 new auditor sub-tests (PASS).
254 lines
7.2 KiB
Go
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
|
|
}
|
|
}
|
|
}
|