The contained runner that drove the 2026-09 security audit, the README review and four rounds of design ideation lived only inside the ignored notebook. It moves to scripts/agent-runner/: the prepare, tools, audit, ideation and teardown scripts, the compose and container files, the allowlist proxy and the prompt templates, with the paths that assumed the notebook fixed and findings written to a caller-named directory. Run outputs, transcripts and the round-specific sheet scripts stay behind. docs/agent-runner.md states the method: the disposable git archive copy and the fail-closed proxy, how a task is shaped, union rather than intersection of findings across models, separate adjudication of every finding against the source, and the evidence a finding must carry.
124 lines
5.6 KiB
Markdown
124 lines
5.6 KiB
Markdown
# member-console — system context for the auditor
|
|
|
|
Supplied because you cannot infer it from a slice of code. These are facts
|
|
about this system, with locations you can verify. Nothing here is a finding.
|
|
|
|
## What the application is
|
|
|
|
A Go web application for worker-cooperative membership: members buy plans,
|
|
which confer entitlements, which provision resources (federated wiki sites,
|
|
forum access, custom domains). Server-rendered `html/template` with HTMX;
|
|
PostgreSQL via sqlc/pgx; Valkey for sessions; Temporal for workflows;
|
|
Keycloak as the OIDC identity provider; Stripe for payments.
|
|
Module `git.coopcloud.tech/wiki-cafe/member-console`.
|
|
|
|
## Two surfaces, one role
|
|
|
|
- `internal/server/shell.go` defines the member and operator surfaces.
|
|
- **The only privileged role is `OperatorRole = "operator-member"`**,
|
|
a literal in `internal/server/operator_partials.go:33`. There is no admin
|
|
role yet.
|
|
- Roles originate in the OIDC ID token and are **stored in the session** at
|
|
login. `HasRole` reads that session copy, not a live token
|
|
(`internal/auth/auth.go:757-782`).
|
|
- There are only three non-test `HasRole` call sites:
|
|
`internal/server/server.go:723`, `:754`, and `internal/server/operator.go:165`.
|
|
|
|
## Request pipeline, in order
|
|
|
|
`internal/server/server.go:668-699` builds `preAuthStack`:
|
|
RequestID → Logging → Recovery → Timeout(32s) → MaxBodySize(1MB) →
|
|
SecureHeaders → CORS → CSRF → Compress. That wraps `routeAware`
|
|
(`:688`), which applies the authentication middleware **only to requests that
|
|
match a registered route pattern**.
|
|
|
|
Server timeouts: Read 4s, Write 8s, Idle 16s, MaxHeaderBytes 1MB.
|
|
|
|
## Authentication middleware
|
|
|
|
`internal/auth/auth.go:126+`. In order:
|
|
1. Public paths pass straight through: `/login`, `/callback`, `/logout`,
|
|
`/logout-callback`, `/register`, `/favicon.ico`, **plus every path passed
|
|
in as `selfAuthenticatedPaths`**.
|
|
2. Any path under `/static/` passes through.
|
|
3. Session must carry `authenticated`.
|
|
4. The session's person is looked up in the database **on every authenticated
|
|
request**; if the row is gone the session is ended and the request bounces
|
|
to `/login`. This is deliberate (a session can outlive its person after an
|
|
identity merge, purge, or demo-database reset).
|
|
|
|
## The exemption coupling — important and deliberate
|
|
|
|
One list, `csrfExemptPaths`, feeds **both** CSRF bypass and authentication
|
|
bypass:
|
|
|
|
- It is seeded with `/domains/ask` (`internal/server/server.go:404`), which is
|
|
unauthenticated by design: a TLS-terminating proxy calls it mid-handshake
|
|
and holds no session and no CSRF token.
|
|
- Each integration appends its own via `RouteMount.CSRFExemptPaths`
|
|
(`server.go:411`).
|
|
- It flows to `csrfConfig.Ignore` (`server.go:561-564`) **and** to
|
|
`authConfig.Middleware(csrfExemptPaths...)` (`server.go:688`).
|
|
|
|
So declaring a path CSRF-exempt also makes it unauthenticated. Current
|
|
declarations: Stripe `/webhooks/stripe`
|
|
(`internal/integrations/stripe/stripe.go:79,147`); Discourse `web.WebhookPath`
|
|
(`internal/integrations/discourse/discourse.go:300`); FedWiki declares none
|
|
(`internal/integrations/fedwiki/fedwiki.go:297`).
|
|
|
|
## Response headers
|
|
|
|
`internal/middleware/security.go` sets `Cache-Control: no-store`,
|
|
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`,
|
|
`Strict-Transport-Security: max-age=3600; includeSubDomains`,
|
|
`Referrer-Policy: no-referrer`, `X-XSS-Protection`, `Cross-Origin-Embedder-Policy:
|
|
require-corp`, `Cross-Origin-Opener-Policy: same-origin`,
|
|
`Cross-Origin-Resource-Policy: same-origin`, and this CSP:
|
|
|
|
```
|
|
default-src 'self'; script-src 'self' https://unpkg.com/htmx.org@*;
|
|
style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self';
|
|
object-src 'none'; frame-ancestors 'none'; form-action 'self'; base-uri 'self';
|
|
```
|
|
|
|
`upgrade-insecure-requests` is appended only when `env == "production"`.
|
|
Project intent, stated in its own docs, is a strict CSP with **no inline
|
|
scripts** and **self-hosted** front-end assets (see `NOTICE` and
|
|
`internal/embeds/static/`).
|
|
|
|
## CSRF
|
|
|
|
`internal/middleware/csrf.go` wraps a gorilla-style CSRF with a 32-byte secret
|
|
that must persist across restarts, per-path `Ignore` predicates, configurable
|
|
`TrustedOrigins`, and configurable cookie `Secure`/`HttpOnly`/`SameSite`.
|
|
Browser writes send the token in an `X-CSRF-Token` header from the page, because
|
|
native form POSTs were failing an Origin check under `Referrer-Policy: no-referrer`.
|
|
|
|
## Tenancy model
|
|
|
|
Person → organization → workspace. A person's session carries person, org, and
|
|
workspace ids (`auth.go:722-747`). Grants form an append-only ledger; what is
|
|
"currently delivering" lives on `pool_provisions.status`, not on `grants.status`.
|
|
Cross-tenant isolation is the property that matters: a member of org A must not
|
|
read or mutate anything belonging to org B.
|
|
|
|
## Trust boundaries
|
|
|
|
| Boundary | Direction | Authenticated by |
|
|
|---|---|---|
|
|
| Browser ↔ app | inbound | session cookie + CSRF token |
|
|
| Keycloak ↔ app | inbound identity | OIDC authorization code flow; ID token |
|
|
| Stripe ↔ app | inbound webhook | provider signature (no session, no CSRF) |
|
|
| Discourse ↔ app | inbound webhook | provider secret (no session, no CSRF) |
|
|
| TLS proxy ↔ app | inbound `/domains/ask` | nothing, by design |
|
|
| app → FedWiki farm | outbound | admin bearer token |
|
|
| app → Stripe API | outbound | restricted key from a file |
|
|
| app → Postgres / Valkey / Temporal | outbound | connection credentials |
|
|
|
|
## What is deliberately test-only
|
|
|
|
Everything under `test/` is fixtures. `test/mc-config.yaml` carries
|
|
`TEST-ONLY-*` secrets on purpose and `test/secrets/` is gitignored. Do not
|
|
report these as leaked credentials. They matter only if the same value or
|
|
pattern reaches a production code path.
|