Files
Alphaeus Mote 63701dd086 feat: real restore, portable secret key, multi-arch image, real CSRF
Addresses the gaps identified in the last audit.

Restore (was a stub returning "not yet implemented"). Every repository shares
one connection pool, so the database cannot be swapped underneath a live
server. Restore is therefore two-phase: RestoreBackup validates the file and
stages it beside the database; db.New applies it before the pool is opened,
which is the only safe moment. The database being replaced is preserved as
<db>.replaced-<timestamp>, and stale -wal/-shm are removed so SQLite cannot
replay the old journal over the restored file. Validation is strict — SQLite
integrity_check plus a schema probe — because applying an unrelated file
would destroy the install. GET/DELETE /api/v1/backups/restore inspect and
cancel a staged restore. The CLI does both phases at once, since it runs
standalone; `orchestrad backup` was also a stub and now works.

Secret key. With nothing configured the key is generated once and persisted
to <data>/secret.key, so restarts reuse it and moving the stack to another
server is a matter of copying the data directory. Upgrades are handled: if a
database already exists the install was silently running on the legacy
built-in default, so that value is adopted and written out rather than
replaced — generating a fresh key there would make every stored credential
undecryptable. The file is owner-only (ACL-restricted on Windows).

Multi-arch image: buildx now emits linux/amd64 + linux/arm64, matching the
architectures the release binaries already covered. The Dockerfile
cross-compiles via TARGETARCH rather than emulating, so arm64 costs little.

CSRF: the middleware previously checked only that a header was *present* and
was never wired up, and /auth/csrf returned "csrf-token-placeholder". Tokens
are now nonce + HMAC-SHA256 signed with the application secret, validated
properly, and the middleware is mounted on /api/v1. Bearer and API-key
requests are not CSRF-reachable and pass through untouched, so this is
transparent to the SPA and to API clients.

Also: the Windows store import drops CRYPT_EXPORTABLE (the store copy is not
the source of truth — <data>/tls holds the key, so portability is unaffected
and a non-exportable server key is the better posture), the PFX password is
written to server.pfx.password beside the bundle so an operator importing it
by hand does not have to hunt for a password they never chose, and the
"renewed" log line now reflects whether a leaf was actually issued instead of
guessing from its age.

Verified live: backup -> stage -> restart applies and preserves the previous
database; secret key generated, adopted, and read back across restarts with
the credential check confirming decryptability; CSRF endpoint issues real
signed tokens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 13:57:15 -04:00

193 lines
5.9 KiB
Go

// Package api - Middleware for authentication and authorization
package api
import (
"context"
"net/http"
"net/url"
"strings"
"github.com/Grace-Solutions/OrchestrAD/internal/auth"
"github.com/Grace-Solutions/OrchestrAD/internal/models"
)
type contextKey string
const userContextKey contextKey = "user"
// scopeContextKey carries the API key scope ("read"/"readwrite"); empty for a
// session, which has full access.
const scopeContextKey contextKey = "scope"
// AuthMiddleware validates session tokens
func AuthMiddleware(authService *auth.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractToken(r)
if token == "" {
WriteError(w, http.StatusUnauthorized, ErrCodeUnauthorized, "Authentication required")
return
}
user, scope, err := validateAuth(authService, r, token)
if err != nil {
WriteError(w, http.StatusUnauthorized, ErrCodeUnauthorized, "Invalid or expired token")
return
}
// A read-scoped API key may only perform safe (read) requests.
if scope == "read" && !isReadMethod(r.Method) {
WriteError(w, http.StatusForbidden, ErrCodeForbidden, "This API key is read-only")
return
}
ctx := context.WithValue(r.Context(), userContextKey, user)
ctx = context.WithValue(ctx, scopeContextKey, scope)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// DocsPageAuthMiddleware protects the human-facing docs page. Unlike
// AuthMiddleware it answers an unauthenticated browser with a redirect to the
// login page (which returns here afterwards) instead of a JSON 401.
func DocsPageAuthMiddleware(authService *auth.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractToken(r)
var user *models.User
var scope string
if token != "" {
user, scope, _ = validateAuth(authService, r, token)
}
if user == nil {
http.Redirect(w, r, "/login?redirect="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound)
return
}
ctx := context.WithValue(r.Context(), userContextKey, user)
ctx = context.WithValue(ctx, scopeContextKey, scope)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// OptionalAuthMiddleware adds user to context if authenticated, but doesn't require it
func OptionalAuthMiddleware(authService *auth.Service) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractToken(r)
if token != "" {
user, _, err := validateAuth(authService, r, token)
if err == nil {
ctx := context.WithValue(r.Context(), userContextKey, user)
r = r.WithContext(ctx)
}
}
next.ServeHTTP(w, r)
})
}
}
// RequireRole creates middleware that requires a specific role
func RequireRole(roles ...string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := GetUserFromContext(r.Context())
if user == nil {
WriteError(w, http.StatusUnauthorized, ErrCodeUnauthorized, "Authentication required")
return
}
if !hasAnyRole(user, roles) {
WriteError(w, http.StatusForbidden, ErrCodeForbidden, "Insufficient permissions")
return
}
next.ServeHTTP(w, r)
})
}
}
// RequireAdmin is a convenience middleware for admin-only routes
func RequireAdmin() func(http.Handler) http.Handler {
return RequireRole("SuperAdmin", "Admin")
}
// RequireOperator is a convenience middleware for operator-level routes
func RequireOperator() func(http.Handler) http.Handler {
return RequireRole("SuperAdmin", "Admin", "Operator")
}
// GetUserFromContext retrieves the authenticated user from context
func GetUserFromContext(ctx context.Context) *models.User {
user, ok := ctx.Value(userContextKey).(*models.User)
if !ok {
return nil
}
return user
}
// GetScopeFromContext returns the caller's API key scope ("read"/"readwrite"),
// or "" for a session (full access).
func GetScopeFromContext(ctx context.Context) string {
scope, _ := ctx.Value(scopeContextKey).(string)
return scope
}
// validateAuth resolves the caller to a user and, for API-key auth, the key's
// scope ("read"/"readwrite"; empty for a session, which is full access). A token
// from the X-API-Key header is validated as an API key; otherwise it is
// validated as a session token, falling back to API-key validation so a key
// sent as a bearer token also works.
func validateAuth(authService *auth.Service, r *http.Request, token string) (*models.User, string, error) {
if r.Header.Get("X-API-Key") != "" {
return authService.ValidateAPIKey(token)
}
user, err := authService.ValidateSession(token)
if err != nil {
if apiUser, scope, apiErr := authService.ValidateAPIKey(token); apiErr == nil {
return apiUser, scope, nil
}
return nil, "", err
}
return user, "", nil
}
func isReadMethod(method string) bool {
return method == http.MethodGet || method == http.MethodHead || method == http.MethodOptions
}
func extractToken(r *http.Request) string {
// Check Authorization header
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
return auth[7:]
}
// Check X-API-Key header for API key auth
if apiKey := r.Header.Get("X-API-Key"); apiKey != "" {
return apiKey
}
// Check cookie (for browser sessions)
cookie, err := r.Cookie("session")
if err == nil && cookie.Value != "" {
return cookie.Value
}
return ""
}
func hasAnyRole(user *models.User, roles []string) bool {
for _, userRole := range user.Roles {
for _, required := range roles {
if userRole.Name == required {
return true
}
}
}
return false
}
// CSRF protection lives in csrf.go: see CSRF.Middleware, which validates
// signed tokens instead of merely checking that a header is present.