mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:21:35 +00:00
99a012e3be
Bundle 1 / Phase 0: pure refactor splitting auth surface out of internal/api/middleware so Bundle 2 (OIDC + sessions) and the broader RBAC primitive (roles, permissions, scoped grants) have a clean home. Moved to internal/auth/: NamedAPIKey, HashAPIKey, AuthConfig, NewAuthWithNamedKeys, NewAuth, UserKey, AdminKey, GetUser, IsAdmin. Added testfixtures.go (WithActor / WithAdmin / WithActorAdmin) so handler tests don't construct context manually. Stayed in internal/api/middleware/: RequestID, Logging, NewLogging, Recovery, RateLimitConfig, NewRateLimiter (now imports auth.GetUser for per-user keying per audit Category C), CORSConfig, NewCORS, ContentType, CORS, GetRequestID, responseWriter, Chain, audit middleware (now imports auth.GetUser). Updated 22 caller files across cmd/, internal/api/handler/, internal/api/middleware/, internal/mcp/. Existing m008_admin_gate_test.go now scans for auth.IsAdmin( substring; Phase 3 will further evolve to track auth.RequirePermission. Behavior unchanged: all handler / middleware / service / connector / cmd / mcp tests pass with no test-logic edits, only import-path renames. Phase 0 exit criteria: internal/auth/ exists with 6 files; middleware.go went 575 -> 422 lines (auth-related ~150 lines moved out); grep -rE 'middleware\.(GetUser|IsAdmin|UserKey|AdminKey|NamedAPIKey|HashAPIKey|NewAuth)' returns 0 hits; context.WithValue(.*middleware.UserKey/AdminKey) returns 0 hits; go vet ./... clean; go test -short ./... green across all packages tested. Branch: dev/auth-bundle-1. Per cowork/auth-bundle-1-prompt.md, do not merge to master without (1) make verify green, (2) >= 2 external testers confirm, (3) >= 90% coverage on internal/auth/ in .github/coverage-thresholds.yml.
155 lines
4.4 KiB
Go
155 lines
4.4 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/auth"
|
|
)
|
|
|
|
// resolveActor extracts the authenticated named-key identity from the request
|
|
// context for audit-trail attribution. Returns the named-key name when set by
|
|
// the auth middleware, or "api" as a safe sentinel when the auth middleware
|
|
// did not populate the context (e.g., AUTH_TYPE=none, or internal/system calls
|
|
// that bypass auth).
|
|
//
|
|
// Post-M-002: this is the single source of truth for handler-layer actor
|
|
// resolution. Handlers must NOT hardcode string literals like "api-key-user"
|
|
// or "api" — always go through this helper so the named-key identity flows to
|
|
// services and the audit trail.
|
|
func resolveActor(ctx context.Context) string {
|
|
if user := auth.GetUser(ctx); user != "" {
|
|
return user
|
|
}
|
|
return "api"
|
|
}
|
|
|
|
// PagedResponse represents a paginated API response.
|
|
type PagedResponse struct {
|
|
Data interface{} `json:"data"`
|
|
Total int64 `json:"total"`
|
|
Page int `json:"page"`
|
|
PerPage int `json:"per_page"`
|
|
}
|
|
|
|
// CursorPagedResponse represents a cursor-paginated API response.
|
|
type CursorPagedResponse struct {
|
|
Data interface{} `json:"data"`
|
|
Total int64 `json:"total"`
|
|
NextCursor string `json:"next_cursor,omitempty"`
|
|
PageSize int `json:"page_size"`
|
|
}
|
|
|
|
// ErrorResponse represents a standard error response.
|
|
type ErrorResponse struct {
|
|
Error string `json:"error"`
|
|
Message string `json:"message"`
|
|
RequestID string `json:"request_id,omitempty"`
|
|
}
|
|
|
|
// JSON writes a JSON response with the given status code and data.
|
|
func JSON(w http.ResponseWriter, status int, data interface{}) error {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
return json.NewEncoder(w).Encode(data)
|
|
}
|
|
|
|
// Error writes a JSON error response with the given status code and message.
|
|
func Error(w http.ResponseWriter, status int, message string) error {
|
|
errResp := ErrorResponse{
|
|
Error: http.StatusText(status),
|
|
Message: message,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
return json.NewEncoder(w).Encode(errResp)
|
|
}
|
|
|
|
// ErrorWithRequestID writes a JSON error response including a request ID.
|
|
func ErrorWithRequestID(w http.ResponseWriter, status int, message, requestID string) error {
|
|
errResp := ErrorResponse{
|
|
Error: http.StatusText(status),
|
|
Message: message,
|
|
RequestID: requestID,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
return json.NewEncoder(w).Encode(errResp)
|
|
}
|
|
|
|
// encodeCursor creates an opaque cursor token from a timestamp and ID.
|
|
func encodeCursor(createdAt time.Time, id string) string {
|
|
raw := createdAt.Format(time.RFC3339Nano) + ":" + id
|
|
return base64.URLEncoding.EncodeToString([]byte(raw))
|
|
}
|
|
|
|
// decodeCursor extracts a timestamp and ID from a cursor token.
|
|
// Kept as var assignment to suppress unused lint — will be used when
|
|
// cursor-based pagination is wired into list handlers.
|
|
var _ = func(cursor string) (time.Time, string, error) {
|
|
raw, err := base64.URLEncoding.DecodeString(cursor)
|
|
if err != nil {
|
|
return time.Time{}, "", fmt.Errorf("invalid cursor: %w", err)
|
|
}
|
|
parts := strings.SplitN(string(raw), ":", 2)
|
|
if len(parts) != 2 {
|
|
return time.Time{}, "", fmt.Errorf("invalid cursor format")
|
|
}
|
|
t, err := time.Parse(time.RFC3339Nano, parts[0])
|
|
if err != nil {
|
|
return time.Time{}, "", fmt.Errorf("invalid cursor timestamp: %w", err)
|
|
}
|
|
return t, parts[1], nil
|
|
}
|
|
|
|
// filterFields removes fields not in the allowed list from the response data.
|
|
// Works with both single objects and slices.
|
|
func filterFields(data interface{}, fields []string) interface{} {
|
|
if len(fields) == 0 {
|
|
return data
|
|
}
|
|
|
|
// Create field set for O(1) lookup
|
|
fieldSet := make(map[string]bool, len(fields))
|
|
for _, f := range fields {
|
|
fieldSet[f] = true
|
|
}
|
|
|
|
// Marshal to JSON, then unmarshal to generic structure
|
|
bytes, err := json.Marshal(data)
|
|
if err != nil {
|
|
return data
|
|
}
|
|
|
|
// Try as array first
|
|
var arr []map[string]interface{}
|
|
if err := json.Unmarshal(bytes, &arr); err == nil {
|
|
for i := range arr {
|
|
for key := range arr[i] {
|
|
if !fieldSet[key] {
|
|
delete(arr[i], key)
|
|
}
|
|
}
|
|
}
|
|
return arr
|
|
}
|
|
|
|
// Try as object
|
|
var obj map[string]interface{}
|
|
if err := json.Unmarshal(bytes, &obj); err == nil {
|
|
for key := range obj {
|
|
if !fieldSet[key] {
|
|
delete(obj, key)
|
|
}
|
|
}
|
|
return obj
|
|
}
|
|
|
|
return data
|
|
}
|