Files
Felis/internal/api/auth.go

156 lines
6.2 KiB
Go

package api
import (
"crypto/subtle"
"fmt"
"net/http"
"strings"
"time"
)
// Principal is the authenticated external-face caller (spec §7, §14). The
// internal face (service token) never produces a Principal — it is a trusted
// machine caller, not a person.
type Principal struct {
// UserID is the account's users.id.
UserID string
// Username is the account's login name.
Username string
// Email is the account's address. Only EmailVerified vouches for it: a player
// can set any address before verifying it (auditActor).
Email string
// Role is "owner", "admin", or "user" (mirrors users.role).
Role string
// ViaAdminAccess is true only when a staff session arrived on the operator
// console host (hostIsAdminConsole). Admin-tier operations require it in
// addition to a staff role (spec §14: ZT is graded by operation). A staff
// session arriving on the player console (console.*) never sets it.
ViaAdminAccess bool
// EmailVerified mirrors users.email_verified. The lockdown middleware gates
// setup-incomplete accounts (EmailVerified=false, e.g. a freshly bootstrapped
// Owner who has not yet proven control of their mailbox) to the setup-wizard
// routes only, so an intercepted setup URL cannot yield full admin access
// before the email-OTP verification step completes.
EmailVerified bool
// ViaSession is true for every principal SessionAuth resolves from a session
// cookie. The session-scoped gates (setup lockdown, reauth, the device list)
// key on it.
ViaSession bool
// ReauthAt is when the holder of the session last proved a factor of the
// account; zero for a session that never did.
ReauthAt time.Time
}
// staffRole reports whether a stored user role carries staff standing: admin,
// or owner (the superset of admin, see IsAdmin). This is the single place the
// role set is spelled out — every staff gate (web IsAdmin, session grading,
// the in-game op-login approve and admin wake) routes through it.
func staffRole(role string) bool {
return role == "admin" || role == "owner"
}
// IsAdmin reports whether the principal may perform admin-tier operations.
// Both the staff role and arrival on the operator console host are required: a
// staff session arriving on the player console must not reach admin routes.
// An owner implicitly passes this check (the owner role is a superset of admin).
func (p *Principal) IsAdmin() bool {
return p != nil && staffRole(p.Role) && p.ViaAdminAccess
}
// IsOwner reports whether the principal holds the platform-level owner role
// — the single identity that may manage users, quotas, and sessions. Only the
// first staff account minted by break-glass carries this role; every subsequent
// Operator is a plain admin. Like IsAdmin, it requires the operator console host.
func (p *Principal) IsOwner() bool {
return p != nil && p.Role == "owner" && p.ViaAdminAccess
}
// Caller names the machine behind an internal-face token. Each caller holds a
// token of its own and each internal route lists the callers it serves
// (apiRoute.Callers), so a token copied out of one namespace opens only what
// that caller needs: the build Job's token reads a submission's context and
// nothing else, and only the proxy and the login gate can mint link codes.
type Caller string
const (
// CallerVelocity is the proxy's felis-link plugin (felis-service-token,
// written into felis-link.properties on the host).
CallerVelocity Caller = "velocity"
// CallerLimbo is the login gate's felis-limbo plugin (felis-limbo-token,
// injected into the login pod only).
CallerLimbo Caller = "limbo"
// CallerBuild is the build Job's context-fetch initContainer
// (felis-build-token in the build namespace).
CallerBuild Caller = "build"
// CallerOps is the on-node console, `felis backup-now` (felis-ops-token,
// control namespace only).
CallerOps Caller = "ops"
)
// InternalAuth authenticates the internal face: a per-caller static token
// presented as a Bearer credential, answered with the caller it belongs to. The
// internal face is never wrapped in Zero Trust (spec §1.8, §14 red line).
type InternalAuth interface {
Authenticate(r *http.Request) (Caller, error)
}
// ExternalAuth authenticates the external face (people / panel) and returns the
// resolved Principal. Production is SessionAuth: the local session cookie the
// sign-in doors mint. Cloudflare Access, when an install sits behind it, is
// enforced at the edge only; felis-api does not read the Access JWT, so the
// identity and role always come from the users table.
type ExternalAuth interface {
Authenticate(r *http.Request) (*Principal, error)
}
// CallerTokens is the production InternalAuth: each caller's token, compared in
// constant time. A caller with no token cannot authenticate, so a missing
// Secret fails closed for that caller alone.
type CallerTokens map[Caller]string
// NewCallerTokens refuses a set that would make the caller ambiguous: two
// callers sharing a value, which is also what an install whose callers all
// still hold the one old service token would look like.
func NewCallerTokens(tokens map[Caller]string) (CallerTokens, error) {
seen := map[string]Caller{}
for caller, tok := range tokens {
if tok == "" {
continue
}
if other, dup := seen[tok]; dup {
return nil, fmt.Errorf("the %s and %s tokens are the same value; each caller needs its own", other, caller)
}
seen[tok] = caller
}
return CallerTokens(tokens), nil
}
// Authenticate matches the Authorization: Bearer header against every caller's
// token, comparing each so the time taken does not say which one matched.
func (c CallerTokens) Authenticate(r *http.Request) (Caller, error) {
got := bearerToken(r)
if got == "" {
return "", fmt.Errorf("missing bearer token")
}
var match Caller
for caller, tok := range c {
if tok != "" && subtle.ConstantTimeCompare([]byte(got), []byte(tok)) == 1 {
match = caller
}
}
if match == "" {
return "", fmt.Errorf("invalid service token")
}
return match, nil
}
// bearerToken extracts a Bearer credential from the Authorization header.
func bearerToken(r *http.Request) string {
const prefix = "Bearer "
h := r.Header.Get("Authorization")
if len(h) > len(prefix) && strings.EqualFold(h[:len(prefix)], prefix) {
return strings.TrimSpace(h[len(prefix):])
}
return ""
}