63701dd086
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>
193 lines
5.9 KiB
Go
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.
|