mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-08-06 21:09:26 +00:00
- Add `revoked_at` column to `api_keys` table for soft-revocation - Add `keys` CLI command group for creating, listing, and revoking keys - Switch vault builder to `zig build-exe` to avoid host runner link errors - Bump version to 0.122.72
243 lines
8.8 KiB
Go
243 lines
8.8 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/DeBrosOfficial/network/pkg/gateway/auth"
|
|
"github.com/DeBrosOfficial/network/pkg/gateway/ctxkeys"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// isKeyMgmtPath reports whether a path is a scoped-key management endpoint
|
|
// (bugboard #148). These are served by the main gateway (authoritative api_keys
|
|
// live in the main cluster RQLite), never proxied to a namespace gateway.
|
|
func isKeyMgmtPath(p string) bool {
|
|
return p == "/v1/namespace/keys" || strings.HasPrefix(p, "/v1/namespace/keys/")
|
|
}
|
|
|
|
// requiredScope returns the API-key grant required to reach (method, path), or
|
|
// "" when a valid credential of any scope suffices. It is the single source of
|
|
// truth for the data-plane vs control-plane split (bugboard #148).
|
|
//
|
|
// Order matters: several prefixes are MIXED (part data-plane, part admin), so
|
|
// the more specific data-plane paths (e.g. /v1/push/devices) must be matched
|
|
// before the coarse admin bucket for the same prefix (/v1/push/config).
|
|
//
|
|
// Public paths (health, auth handshake, function invoke) are exempted by the
|
|
// gate before this is called; the isPublicPath short-circuit here is a belt-
|
|
// and-braces guard so a stray classification can never over-restrict them.
|
|
func requiredScope(method, path string) string {
|
|
if isPublicPath(path) {
|
|
return ""
|
|
}
|
|
|
|
// --- Functions: /invoke is public (handled above); /ws is an invoke
|
|
// transport (data-plane); everything else is control-plane. ---
|
|
if path == "/v1/functions" || strings.HasPrefix(path, "/v1/functions/") {
|
|
if strings.HasSuffix(path, "/ws") {
|
|
return auth.ScopeInvoke
|
|
}
|
|
return auth.ScopeAdmin
|
|
}
|
|
|
|
// --- Storage (data-plane) ---
|
|
if strings.HasPrefix(path, "/v1/storage/") {
|
|
return auth.ScopeStorage
|
|
}
|
|
|
|
// --- Push: devices are data-plane; config/send are admin ---
|
|
if path == "/v1/push/devices" || strings.HasPrefix(path, "/v1/push/devices/") {
|
|
return auth.ScopePush
|
|
}
|
|
if strings.HasPrefix(path, "/v1/push/") {
|
|
return auth.ScopeAdmin
|
|
}
|
|
if path == "/v1/namespace/push-credentials" || strings.HasPrefix(path, "/v1/namespace/push-credentials/") {
|
|
return auth.ScopeAdmin
|
|
}
|
|
|
|
// --- WebRTC data-plane (signal/rooms/turn credentials) ---
|
|
if strings.HasPrefix(path, "/v1/webrtc/") {
|
|
return auth.ScopeWebRTC
|
|
}
|
|
// Namespace WebRTC management: enable/disable/stealth are admin; status is a
|
|
// read that any valid credential may poll.
|
|
if strings.HasPrefix(path, "/v1/namespace/webrtc/") {
|
|
if strings.HasSuffix(path, "/status") {
|
|
return ""
|
|
}
|
|
return auth.ScopeAdmin
|
|
}
|
|
|
|
// --- Anon proxy (data-plane) ---
|
|
if strings.HasPrefix(path, "/v1/proxy/") {
|
|
return auth.ScopeProxy
|
|
}
|
|
|
|
// --- Pub/sub REST (data-plane grant; not in anchat profiles) ---
|
|
if strings.HasPrefix(path, "/v1/pubsub/") {
|
|
return auth.ScopePubsub
|
|
}
|
|
|
|
// --- Olric cache REST (data-plane grant; dead in client) ---
|
|
if strings.HasPrefix(path, "/v1/cache/") {
|
|
return auth.ScopeCache
|
|
}
|
|
|
|
// --- Control-plane (admin only) ---
|
|
if path == "/rqlite" || path == "/v1/rqlite" || strings.HasPrefix(path, "/v1/rqlite/") {
|
|
return auth.ScopeAdmin
|
|
}
|
|
if strings.HasPrefix(path, "/v1/deployments/") {
|
|
return auth.ScopeAdmin
|
|
}
|
|
if strings.HasPrefix(path, "/v1/db/sqlite/") {
|
|
return auth.ScopeAdmin
|
|
}
|
|
if strings.HasPrefix(path, "/v1/serverless/") {
|
|
return auth.ScopeAdmin
|
|
}
|
|
if path == "/v1/namespace/rate-limit" {
|
|
return auth.ScopeAdmin
|
|
}
|
|
if path == "/v1/namespace/keys" || strings.HasPrefix(path, "/v1/namespace/keys/") {
|
|
return auth.ScopeAdmin
|
|
}
|
|
if path == "/v1/namespace/delete" || path == "/v1/namespace/list" {
|
|
return auth.ScopeAdmin
|
|
}
|
|
|
|
// Default: a valid credential is enough; no elevated grant required.
|
|
return ""
|
|
}
|
|
|
|
// callerScopes resolves the effective grant set for the authenticated request.
|
|
//
|
|
// - API-key-exchanged JWT (from /v1/auth/token): carries the key's exact
|
|
// scopes in a custom claim — this is what closes the exchange-then-escalate
|
|
// hole (a runtime key exchanged for a JWT keeps its narrow scope).
|
|
// - SIWE wallet JWT: a confirmed namespace owner gets admin; any other
|
|
// authenticated user gets the data-plane set (never admin).
|
|
// - Raw API key: the scopes stashed at lookup time.
|
|
func (g *Gateway) callerScopes(r *http.Request) auth.ScopeSet {
|
|
ctx := r.Context()
|
|
|
|
if v := ctx.Value(ctxKeyJWT); v != nil {
|
|
if claims, ok := v.(*auth.JWTClaims); ok && claims != nil {
|
|
// Only an API-key-exchanged JWT (ak_ subject) may carry an
|
|
// authoritative scopes claim. A SIWE wallet JWT must NOT be trusted
|
|
// here even if a custom["scopes"] is present, because a tenant
|
|
// claims-provider could otherwise inject "admin" for every end-user
|
|
// (defense-in-depth alongside reserving "scopes" in the provider).
|
|
if isAPIKeySubject(claims.Sub) && claims.Custom != nil {
|
|
if raw := strings.TrimSpace(claims.Custom["scopes"]); raw != "" {
|
|
return auth.ParseScopes(raw)
|
|
}
|
|
}
|
|
if confirmed, _ := ctx.Value(ctxKeyOwnerConfirmed).(bool); confirmed {
|
|
return auth.ScopeSet{auth.ScopeAdmin: {}}
|
|
}
|
|
return auth.DataPlaneScopes()
|
|
}
|
|
}
|
|
|
|
if v := ctx.Value(ctxKeyScopes); v != nil {
|
|
if s, ok := v.(auth.ScopeSet); ok {
|
|
return s
|
|
}
|
|
}
|
|
|
|
// No identity resolved (should not happen for a non-public path, which the
|
|
// auth middleware already gated) — deny by returning an empty set.
|
|
return auth.ScopeSet{}
|
|
}
|
|
|
|
// requiresUserJWT reports whether a data-plane grant additionally requires a
|
|
// genuine per-user (wallet) JWT — the layer-1 hardening that makes an extracted
|
|
// runtime key worthless without a logged-in user. Admin callers are exempt (see
|
|
// scopeMiddleware); push already enforces this in its own handler.
|
|
func requiresUserJWT(grant string) bool {
|
|
switch grant {
|
|
case auth.ScopeStorage, auth.ScopeWebRTC, auth.ScopeProxy:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isAPIKeySubject reports whether a JWT subject is an API key (ak_<rand>:<ns>),
|
|
// as minted by the API-key→JWT exchange, rather than a SIWE wallet address.
|
|
// This is the single signal used to (a) decide a JWT is not a genuine user
|
|
// (hasWalletJWT) and (b) decide whether to trust an embedded scopes claim
|
|
// (callerScopes). Wallet subjects are plain addresses; only exchanged keys
|
|
// carry the ak_ prefix.
|
|
func isAPIKeySubject(sub string) bool {
|
|
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(sub)), "ak_")
|
|
}
|
|
|
|
// hasWalletJWT reports whether the request carries a genuine end-user (SIWE
|
|
// wallet) JWT — as opposed to an API-key-exchanged JWT (sub is the key). This
|
|
// is what layer-1 accepts: an exchanged runtime-key JWT must NOT satisfy it,
|
|
// or the escalation hole reopens.
|
|
func hasWalletJWT(r *http.Request) bool {
|
|
if v := r.Context().Value(ctxKeyJWT); v != nil {
|
|
if claims, ok := v.(*auth.JWTClaims); ok && claims != nil {
|
|
sub := strings.TrimSpace(claims.Sub)
|
|
if sub == "" {
|
|
return false
|
|
}
|
|
return !isAPIKeySubject(sub) // an exchanged-key JWT is not a user
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// scopeMiddleware enforces the API-key scope model. It runs after the
|
|
// authorization (ownership) middleware, so ownership has already been verified;
|
|
// this layer additionally (a) rejects a credential whose grant set does not
|
|
// cover the operation (403 INSUFFICIENT_SCOPE, bugboard #148), and (b) requires
|
|
// a genuine user JWT for the storage/webrtc/proxy data-plane grants unless the
|
|
// caller is admin (the layer-1 hardening — an extracted runtime key is useless
|
|
// without a logged-in user).
|
|
func (g *Gateway) scopeMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodOptions || isPublicPath(r.URL.Path) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
required := requiredScope(r.Method, r.URL.Path)
|
|
if required == "" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
scopes := g.callerScopes(r)
|
|
if !scopes.Has(required) {
|
|
g.logger.ComponentWarn("gateway", "request rejected: insufficient scope",
|
|
zap.String("path", r.URL.Path),
|
|
zap.String("required_scope", required),
|
|
)
|
|
writeError(w, http.StatusForbidden,
|
|
"insufficient scope: this credential lacks the '"+required+"' grant required for "+r.URL.Path)
|
|
return
|
|
}
|
|
if requiresUserJWT(required) && !scopes.IsAdmin() && !hasWalletJWT(r) {
|
|
g.logger.ComponentWarn("gateway", "request rejected: user JWT required",
|
|
zap.String("path", r.URL.Path),
|
|
zap.String("required_scope", required),
|
|
)
|
|
writeError(w, http.StatusUnauthorized,
|
|
"user authentication required (JWT): the '"+required+"' operation requires a logged-in user; an API key alone is not sufficient")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// markOwnerConfirmed returns a shallow copy of the request whose context records
|
|
// that a SIWE wallet owner was verified for the namespace (used by callerScopes
|
|
// to grant the owner admin via a wallet JWT).
|
|
func markOwnerConfirmed(r *http.Request) *http.Request {
|
|
return r.WithContext(context.WithValue(r.Context(), ctxkeys.OwnerConfirmed, true))
|
|
}
|