Skip to content

feat(api): auth modes, login sessions and mutation audit trail - #858

Closed
Sanjin-Maker wants to merge 2 commits into
srcfl:masterfrom
Sanjin-Maker:agent/auth-enforcement
Closed

feat(api): auth modes, login sessions and mutation audit trail#858
Sanjin-Maker wants to merge 2 commits into
srcfl:masterfrom
Sanjin-Maker:agent/auth-enforcement

Conversation

@Sanjin-Maker

Copy link
Copy Markdown
Contributor

Summary

Stacked on #857 (localauth foundation) — review the last commit until it lands. Composes with draft #744: that PR widens which reads count as protected inside SecureMutations; this one adds an identity layer outside it — no shared hunks, whoever lands second rebases trivially.

  • api.auth.mode (config.API.Auth):
    • open (default) — byte-identical to today; verified by the untouched api test suite.
    • local_trust — local clients unchanged; non-local requests need a session (viewer to read, operator to mutate). FTW_API_TOKEN bearer mutations keep working for automation.
    • required — every /api request needs a session, local included; /api/auth/login, /api/health and static assets (the login page) stay reachable.
  • Endpoints: POST /api/auth/login (uniform invalid-credentials error — no username oracle; HttpOnly SameSite=Strict cookie), logout, GET /api/auth/session, GET /api/audit.
  • Audit: every mutation attempt (not just successes) recorded with principal (username / token / local), method, path, remote addr — in ALL modes including open. Failed logins audited as login-failed:<name>.
  • ftw user CLI (add/list/passwd/disable/enable/delete): the bootstrap channel that needs no prior credential. Refuses to remove the last enabled operator while a login mode is active; startup refuses non-open modes with zero operators — a config typo can never lock the operator out of the box.
  • Layering: RequireAuth wraps inside the boot-phase SecureMutations (CSRF/content-type garbage rejected first, then identity), applied at the handler swap so the boot-phase health path is untouched.
  • x/crypto → direct, x/term added (password prompt) at the already-resolved module versions — no transitive changes.

Verification

go test ./internal/api/ ./internal/state/ ./internal/config/ ./cmd/ftw/ -count=1 — middleware matrix: open pass-through + audit, local_trust local-unchanged/remote-401/operator-pass, viewer-403-on-mutation, required gates local + exempt paths reachable, bearer automation + forged-token rejection, principal recording; audit table round-trip; config mode validation. DCO signed; minor changeset.

🤖 Generated with Claude Code

Sanjin-Maker and others added 2 commits August 6, 2026 22:52
users table (operator/viewer, argon2id PHC hashes) in state, plus
go/internal/localauth: constant-time password verification at OWASP
argon2id parameters and in-memory bearer sessions with expiry and
per-user revocation. Sessions are memory-only on purpose — restart
logs everyone out, and no session secret touches the database. API
enforcement comes separately; nothing changes for existing installs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Sanjin Naidu <sanjin@sanrowconsulting.com>
api.auth.mode open|local_trust|required layered outside the existing
SecureMutations gate: open is byte-identical to today, local_trust
gates only non-local requests (viewer reads, operator mutations,
bearer token still valid for automation), required gates everything
but login/health/static. ftw user CLI bootstraps accounts on the box;
startup refuses login modes with zero enabled operators. Every
mutation attempt lands in audit_log with its principal, served at
GET /api/audit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Sanjin Naidu <sanjin@sanrowconsulting.com>
@Sanjin-Maker
Sanjin-Maker force-pushed the agent/auth-enforcement branch from 001fa8a to bdd43ef Compare August 6, 2026 20:53
@frahlg

frahlg commented Aug 7, 2026

Copy link
Copy Markdown
Member

The production handler chain currently breaks the new remote-session model:

  • main keeps an outer SecureMutations(apiMutationPolicy()) around the new auth layer. It rejects remote POST /api/auth/login without the old bearer token, and it also rejects remote operator mutations even when the request has a valid session cookie.
  • Server.Handler() adds RequireAuth, then main adds it again. Each accepted mutation reaches both wrappers and gets two audit rows.
  • An unauthenticated GET /api/auth/session is not exempt, so it returns 401 before the UI can learn that login is required.
  • Sessions cache role and enabled state for 24 hours. Disabling, deleting, or changing the password of a compromised account does not invalidate its live sessions.
  • Login has no attempt limit, while every attempt runs Argon2 and appends an audit row. Audit rows have a prune method but no caller, so this is an unbounded DB/write path.

Please define one production wrapper order, let a valid operator session authorize remote mutations, keep login/session discovery reachable through that boundary, and make account changes revoke or revalidate sessions. Add an end-to-end handler test using the same nesting as main, plus rate and retention limits.

@miravoss26 miravoss26 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the last commit (feat(api): auth modes, login sessions and mutation audit trail) per the stacked-PR note. Solid design overall (identity layer outside SecureMutations, audit-on-attempt not just success, bootstrap-safe ftw user CLI, mode open byte-identical by construction) — but the security screen found two real issues worth a look before this lands.

1. GET /api/audit is unauthenticated in the default open mode — contradicts its own comment. In auth.go, handleAuditLog's role check is if s.deps.Auth.enabled() { ... }, and RequireAuth itself no-ops entirely when !p.enabled(). Since open is the default and enabled() is false for it, any caller who can reach the API gets the full mutation/login audit trail — principals (including attempted usernames via login-failed:<name>), methods, paths, remote addresses — with zero auth. The handler's own comment says "operators only (enforced in-handler so the endpoint is protected even in open mode)," which isn't what the code does. TestOpenModeIsPassThrough confirms open-mode pass-through for /api/status and /api/mode but doesn't cover /api/audit, so this isn't caught by the new suite either. Since open is meant to be "today's behavior, byte-identical," and this is a new endpoint, the fix is just: gate /api/audit on operator role unconditionally, not only if enabled().

2. Login has a timing side-channel that undermines the "no username oracle" goal. authOK := err == nil && ok && !u.Disabled && localauth.VerifyPassword(...) short-circuits on ok — when the username doesn't exist, VerifyPassword (presumably bcrypt/argon2-cost) never runs, so a nonexistent-user login returns much faster than a real-user-wrong-password one. The response body is uniform ("invalid credentials"), but response timing isn't, which is exactly the oracle the comment says it's avoiding. Usual fix: always run the hash verification (against a fixed dummy hash when the user doesn't exist) so both paths cost the same.

Minor: the session cookie sets HttpOnly + SameSite=Strict but not Secure. Probably fine if this only ever runs behind TLS termination, but FTW looks like it can run bare on a local network — worth confirming that's intentional rather than an oversight.

Nothing here blocks the architecture (mode semantics, bootstrap CLI, layering vs SecureMutations all read right), but #1 is a real infoleak in the default mode and I'd want a human decision on both #1 and #2 before merge.

frahlg commented Aug 8, 2026

Copy link
Copy Markdown
Member

Thank you @Sanjin-Maker for the large test matrix and the attention to login, roles and audit.

We are closing this cumulative branch pending the architecture decision described on #857. The current handler order blocks valid login/session mutations in some modes, applies auth twice in parts of the chain, keeps role and enabled state in long-lived sessions, lacks login attempt limits and leaves audit pruning unwired. The default-open audit endpoint and cookie/session behavior also need a written threat model.

Please carry observations or proposed rules into a text issue first. We should agree on the boundary before writing another API implementation.

@frahlg frahlg closed this Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants