orama/core/pkg/gateway/auth/scopes.go
anonpenguin23 004639d3d2 feat(core): implement scoped API key management
- 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
2026-07-04 20:25:18 +03:00

174 lines
5.7 KiB
Go

package auth
import (
"fmt"
"sort"
"strings"
)
// API-key scope grants (bugboard #148). A key carries a SET of these, stored
// comma-separated in api_keys.scopes. Grants gate which classes of endpoint a
// key may reach; the per-endpoint policy (which grant a given route needs)
// lives in the gateway package (requiredScope). The admin grant is a wildcard
// that satisfies every requirement.
//
// The split is data-plane (safe to ship in a public client bundle) vs
// control-plane (admin — never leaves CI/dev):
// - data-plane: invoke, storage, push, webrtc, proxy, pubsub, cache
// - control-plane: admin (deploy, secrets, migrations, config, key mgmt, raw rqlite)
const (
ScopeAdmin = "admin" // full control-plane
ScopeInvoke = "invoke" // invoke functions (data access still gated by per-user JWT)
ScopeStorage = "storage" // IPFS storage upload/pin/get
ScopePush = "push" // push device registration
ScopeWebRTC = "webrtc" // TURN credentials + SFU signaling
ScopeProxy = "proxy" // Anyone-routed anon proxy
ScopePubsub = "pubsub" // pub/sub REST
ScopeCache = "cache" // Olric cache REST
)
// knownGrants is the set of every valid grant. Used to validate issuance input
// so a typo can't mint a key with a grant no route will ever satisfy.
var knownGrants = map[string]struct{}{
ScopeAdmin: {},
ScopeInvoke: {},
ScopeStorage: {},
ScopePush: {},
ScopeWebRTC: {},
ScopeProxy: {},
ScopeCache: {},
}
// ScopeSet is a parsed, membership-tested set of grants held by a key.
type ScopeSet map[string]struct{}
// Has reports whether the set satisfies the required grant. An empty required
// grant is always satisfied (the endpoint needs only a valid credential). The
// admin grant satisfies every requirement.
func (s ScopeSet) Has(required string) bool {
if required == "" {
return true
}
if _, ok := s[ScopeAdmin]; ok {
return true
}
_, ok := s[required]
return ok
}
// IsAdmin reports whether the set holds the admin (full control-plane) grant.
func (s ScopeSet) IsAdmin() bool {
_, ok := s[ScopeAdmin]
return ok
}
// Canonical renders the set as a stable, sorted, comma-separated string —
// suitable for storage in api_keys.scopes and for embedding in a JWT claim.
func (s ScopeSet) Canonical() string {
if len(s) == 0 {
return ""
}
out := make([]string, 0, len(s))
for g := range s {
out = append(out, g)
}
sort.Strings(out)
return strings.Join(out, ",")
}
// ParseScopes parses a literal comma-separated grant string into a set. It does
// NOT apply the grandfather policy — an empty string yields an empty set. Use
// ScopesFromStored for the api_keys.scopes read path.
func ParseScopes(raw string) ScopeSet {
set := ScopeSet{}
for _, part := range strings.Split(raw, ",") {
g := strings.ToLower(strings.TrimSpace(part))
if g != "" {
set[g] = struct{}{}
}
}
return set
}
// ScopesFromStored converts a stored api_keys.scopes value into a ScopeSet,
// applying the grandfather policy: a NULL/empty scopes column means the key was
// minted before scoping existed, so it is treated as admin (full access). This
// keeps every legacy key working across the rolling upgrade; the cutover then
// revokes all legacy (NULL-scope) keys so this branch stops mattering.
func ScopesFromStored(raw string) ScopeSet {
if strings.TrimSpace(raw) == "" {
return ScopeSet{ScopeAdmin: {}}
}
return ParseScopes(raw)
}
// DataPlaneScopes is the grant set an authenticated end-user (SIWE wallet JWT)
// receives: every data-plane grant, never admin. A logged-in user may use
// storage/push/webrtc/proxy/invoke; only the admin API key may touch the
// control-plane.
func DataPlaneScopes() ScopeSet {
return ScopeSet{
ScopeInvoke: {},
ScopeStorage: {},
ScopePush: {},
ScopeWebRTC: {},
ScopeProxy: {},
ScopePubsub: {},
ScopeCache: {},
}
}
// ProfileGrants maps a named key profile to its grant list. Profiles are the
// ergonomic way to mint the three shipped key tiers; a raw grant list is also
// accepted by the issuance path. Returns false for an unknown profile.
func ProfileGrants(profile string) ([]string, bool) {
switch strings.ToLower(strings.TrimSpace(profile)) {
case "admin":
return []string{ScopeAdmin}, true
case "app-runtime", "runtime", "app":
return []string{ScopeInvoke, ScopeStorage, ScopePush, ScopeWebRTC, ScopeProxy}, true
case "invoke-only", "invoke":
return []string{ScopeInvoke}, true
}
return nil, false
}
// NormalizeGrants validates and canonicalizes a requested grant list (either a
// profile name or explicit comma/space-separated grants) into a stable stored
// string. Returns an error naming any unknown grant so issuance fails loud
// rather than minting a key that no route can satisfy.
func NormalizeGrants(requested string) (string, error) {
requested = strings.TrimSpace(requested)
if requested == "" {
return "", fmt.Errorf("scope is required (a profile name or grant list)")
}
if grants, ok := ProfileGrants(requested); ok {
return ScopeSet(setOf(grants)).Canonical(), nil
}
// Explicit grant list — accept comma OR space separated.
fields := strings.FieldsFunc(requested, func(r rune) bool { return r == ',' || r == ' ' })
set := ScopeSet{}
for _, f := range fields {
g := strings.ToLower(strings.TrimSpace(f))
if g == "" {
continue
}
if _, ok := knownGrants[g]; !ok {
return "", fmt.Errorf("unknown grant %q (valid: admin, invoke, storage, push, webrtc, proxy, pubsub, cache)", g)
}
set[g] = struct{}{}
}
if len(set) == 0 {
return "", fmt.Errorf("no valid grants in %q", requested)
}
return set.Canonical(), nil
}
func setOf(grants []string) map[string]struct{} {
m := make(map[string]struct{}, len(grants))
for _, g := range grants {
m[g] = struct{}{}
}
return m
}