Files
Felis/internal/api/session.go
T
flyemoji 2d0bbb0c37 feat(cli): attribute break-glass recovery to the SysAdmin who runs it
Root is machine authority, not a human identity, so `felis breakGlass`
now also records WHICH SysAdmin broke the glass. Even under
`sudo felis breakGlass` an account and password are entered in the TUI;
the root gate is necessary but no longer sufficient for accountability.

The console resolves one of three modes up front and audits the
difference:

- bootstrap (no staff account exists yet): the typed credential mints
  the first Owner; the act is attributed to the OS user ($SUDO_USER,
  else root) and recorded verified:false.
- recovery (an admin already exists): the operator authenticates as an
  existing admin via bcrypt; the verified identity is the accountable
  actor and the row is recorded verified:true.
- root override (the typed credential did not verify): a deliberate
  OVERRIDE token proceeds under local-root authority, attributed to the
  OS user and recorded verified:false. Break-glass never refuses -
  recovering when no admin password can be produced is its whole job.

Attribution is best-effort, not proof (whoever runs this is root and can
edit Postgres directly); the audit row is honest about which it is.

- internal/api: AuditEntry gains an optional jsonb Payload (nil maps to
  SQL NULL, so existing callers are unaffected); PGRepo.Audit writes it
  and a new PGRepo.AdminExists drives the bootstrap-vs-recovery switch.
- the accountability row is written the instant the credential changes,
  before local auth is enabled, so a failed toggle write can never leave
  a reset credential with no "who did it" record.
- local_auth_enabled is now one exported api.LocalAuthEnabledKey shared
  by the break-glass writer and the per-request reader, replacing two
  drifting copies of the literal.
- break-glass password entry reuses the panel's 8-72-byte rule so a
  credential set here is never later rejected by web change-password.

Covered by Go unit tests over a fake owner store: auth match/non-match,
the three audit modes and their payloads, that a dead audit sink does
not fail the recovery, that the audit precedes the toggle write, and a
headless drive of the TUI state machine asserting no credential reaches
provisioning without a verified admin or an explicit OVERRIDE.
2026-06-27 11:48:41 +09:00

184 lines
6.4 KiB
Go

package api
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
)
// Local-password sessions (spec §B). The remote face authenticates statelessly
// with a Cloudflare-Access JWT and sets no cookie; local-password auth, used on
// op.console when Zero Trust is not configured (and as the demo's primary web
// login), needs a server-minted session. We store only the sha-256 of the opaque
// cookie value, mirroring how service tokens are stored, so a database read never
// yields a usable cookie.
const (
// sessionCookieName is the host-only session cookie. It carries no Domain
// attribute, so an op.console session is never sent to the player console.
sessionCookieName = "felis_session"
// sessionTTL bounds a local-password session. Staff re-authenticate after it.
sessionTTL = 12 * time.Hour
)
// LocalAuthEnabledKey is the platform_settings key that gates whether
// local-password sessions are honored. It is flipped on by `felis breakGlass`
// direct-to-Postgres at first-run and read live per-request, so enabling local
// auth needs no pod roll. Exported so the break-glass writer and this
// per-request reader share one source of truth instead of drifting copies.
const LocalAuthEnabledKey = "local_auth_enabled"
// newSessionToken returns a fresh opaque session value (256 bits, URL-safe). It
// is the cookie value; only its hash is persisted.
func newSessionToken() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("generate session token: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b[:]), nil
}
// hashCookie maps a cookie value to its storage key (sha-256 hex), so the raw
// cookie is never written to the database.
func hashCookie(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
// setSessionCookie writes the session cookie: HttpOnly + Secure + SameSite=Lax,
// host-only (no Domain), rooted at "/". Secure means the console must be served
// over HTTPS — already a hard requirement, since WebAuthn and Zero Trust both
// demand a secure context.
func setSessionCookie(w http.ResponseWriter, value string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: value,
Path: "/",
Expires: expires,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
// clearSessionCookie expires the session cookie (logout). The attributes must
// match setSessionCookie for the browser to overwrite it.
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
// hostIsAdminConsole reports whether the request arrived on the operator console
// host, op.console.<root_domain>. The session cookie is host-only, so a session
// minted on op.console is structurally unable to reach the player console; this
// is the local-auth analogue of the admin Access path. The Host the API sees must
// be the real client Host (the ingress must forward it), which the VM check
// verifies.
func hostIsAdminConsole(r *http.Request, rootDomain string) bool {
if rootDomain == "" {
return false
}
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
want := "op.console." + rootDomain
return strings.EqualFold(strings.TrimSuffix(host, "."), want)
}
// SessionAuth is the composite ExternalAuth for the web face. It prefers a
// local-password session cookie and otherwise delegates to the remote JWT
// verifier, so both auth models coexist on one face:
//
// - No cookie → delegate to Delegate (the Cloudflare-Access JWT path).
// - Cookie set → local auth MUST be enabled (a missing or non-true
// local_auth_enabled setting is treated as disabled — fail closed); the
// session hash must resolve to a live user. On any failure the request is
// rejected and does NOT fall through to the JWT delegate, so a stale or
// forged cookie can never be laundered into a JWT attempt.
type SessionAuth struct {
Repo Repo
Delegate ExternalAuth
RootDomain string
Now func() time.Time
}
func (s SessionAuth) now() time.Time {
if s.Now != nil {
return s.Now()
}
return time.Now()
}
// Authenticate resolves the caller from a session cookie or delegates to the JWT
// verifier (see the type comment for the fail-closed rules).
func (s SessionAuth) Authenticate(r *http.Request) (*Principal, error) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
// No usable session cookie: this is the remote JWT path.
if s.Delegate == nil {
return nil, fmt.Errorf("external auth not configured")
}
return s.Delegate.Authenticate(r)
}
ctx := r.Context()
if !localAuthEnabled(ctx, s.Repo) {
// A cookie was presented but local auth is off: reject, never fall through.
return nil, fmt.Errorf("local auth disabled")
}
u, err := s.Repo.SessionUser(ctx, hashCookie(cookie.Value), s.now())
if err != nil {
return nil, fmt.Errorf("invalid session: %w", err)
}
return &Principal{
UserID: u.ID,
Email: u.Email,
Role: u.Role,
ViaAdminAccess: u.Role == "admin" && hostIsAdminConsole(r, s.RootDomain),
MustChangePassword: u.MustChangePassword,
}, nil
}
// localAuthEnabled reports whether the runtime local_auth_enabled toggle is true.
// A missing setting, a read error, or a non-true value all read as disabled — the
// gate fails closed so local sessions are honored, and new ones minted, only on an
// explicit opt-in. Both SessionAuth (honoring a cookie) and the login handler
// (minting one) consult it, so the two never disagree about whether local auth is
// live.
func localAuthEnabled(ctx context.Context, repo Repo) bool {
raw, err := repo.GetSetting(ctx, LocalAuthEnabledKey)
if err != nil {
return false // ErrNotFound (never enabled) or a transient read error → closed
}
var enabled bool
if err := json.Unmarshal(raw, &enabled); err != nil {
return false
}
return enabled
}
// ensure SessionAuth satisfies ExternalAuth at compile time.
var _ ExternalAuth = SessionAuth{}
// errIsNotFound is a small helper so handlers can branch on the repo's sentinel
// without importing errors at every call site.
func errIsNotFound(err error) bool { return errors.Is(err, ErrNotFound) }