initial commit

This commit is contained in:
Daniel Noulet 2026-10-01 23:15:57 +02:00
commit 54576d9d33
61 changed files with 5569 additions and 0 deletions

80
docs/architecture.md Normal file
View file

@ -0,0 +1,80 @@
# Architecture and decisions
## Scope
Go Loose is an authorization control plane, not an API gateway. Protected applications keep serving their own traffic and use the client middleware to ask Go Loose whether an incoming key is authorized. This minimizes coupling and lets a service adopt Go Loose one route group at a time.
## Components
```text
cmd/go-loose
configuration → migrations → HTTP server
internal/server
OIDC/session auth, management API, authorization endpoint, embedded web UI
internal/store
tenant-isolated PostgreSQL queries and authorization audit writes
internal/key
cryptographically random key generation and one-way lookup hashing
internal/contract
OpenAPI 2/3 JSON/YAML normalization and endpoint extraction
client
public net/http middleware package used by other Go modules
```
PostgreSQL is the source of truth. The frontend is dependency-free HTML/CSS/JavaScript embedded in the Go binary. This creates one immutable artifact and avoids a second package ecosystem while the product is young.
## Tenant model
- A **tenant** is an organization such as `nmbs`.
- An **application** is a protected API such as `guess` and always belongs to one tenant.
- An **API key** belongs to exactly one application and tenant.
- A **membership** grants a user `owner`, `admin`, or `viewer` access to a tenant.
- An **application access grant** allows a user to sign in to one client application.
- `allowed_hosts` optionally binds keys for an application to hosts such as `nmbs.guess.local`.
- Tenant and application slugs are sent by middleware, so identical application names can safely exist in different tenants.
Hostnames are authorization context, not tenant identity. Do not trust a host alone: the key, expected tenant, expected application, and optional allowed host must all agree.
## Security decisions
1. **Fail closed.** Missing keys, Go Loose outages, malformed responses, inactive keys, expired keys, scope mismatches, and host mismatches deny the request.
2. **No plaintext key persistence.** Keys contain 256 random bits and are stored as SHA-256 digests. High-entropy secrets do not require a slow password hash for offline resistance.
3. **Session isolation.** Management uses signed, HTTP-only, SameSite cookies. Mutations additionally require the configured same origin.
4. **OIDC validation.** The server uses provider discovery and validates issuer, audience, signature, and expiry through `go-oidc`.
5. **SSRF resistance.** Contract import allows only HTTP(S), resolves hostnames, and blocks loopback/private/link-local destinations unless explicitly enabled.
6. **Auditing.** Every runtime decision records tenant, application, key, route context, outcome, and reason. Raw keys are never logged.
7. **Least privilege.** Management writes require tenant `owner` or `admin`. Read access requires membership.
8. **Separate login purpose.** Client login sessions cannot open the management console. Hosted login uses exact callback matching, one-time codes, confidential client secrets, and opaque revocable sessions.
For production, add rate limiting at the ingress, PostgreSQL backups, metrics/tracing, audit retention, membership invitations, key rotation overlap, and a tightly controlled egress proxy for contract scanning.
## OpenAPI contracts
Imported OpenAPI 2.x/3.x documents are normalized to JSON and stored unchanged in meaning. Methods, paths, and operation IDs are extracted into relational rows for discovery. Contracts are currently informational: they do not restrict runtime paths. Enforcing contract endpoints later should be opt-in per application to avoid breaking existing consumers.
## Decision log
| Decision | Choice | Reason |
|---|---|---|
| Deployment | One Go binary | Simple operations and atomic frontend/backend releases |
| Database | PostgreSQL | Strong tenant relations, transactions, JSONB contracts, and mature operations |
| Router | Go `http.ServeMux` | Modern method/path routing without another framework |
| UI | Embedded vanilla web assets | Fast startup, small supply chain, no Node runtime/build |
| Identity | OIDC Authorization Code flow | Works with Keycloak, Entra ID, Auth0, Okta, and other compliant IdPs |
| API keys | Random opaque secret + SHA-256 digest | Efficient indexed validation without recoverable secrets |
| Migrations | Embedded ordered SQL | Reviewable schema history and automatic transactional upgrades |
| Runtime integration | Remote authorization middleware | Immediate revocation and centralized audit; availability must be engineered |
## Planned evolution
1. Membership invitations and IdP group-to-role mapping.
2. Key rotation pairs and usage analytics.
3. Optional short-lived signed authorization assertions to reduce control-plane latency.
4. Per-application contract enforcement and route-level scopes.
5. Webhooks and audit export.
6. High-availability deployment, telemetry, and policy-based rate limits.

134
docs/client-login.md Normal file
View file

@ -0,0 +1,134 @@
# Client application login
Go Loose can host login for browser-facing Go applications. Your application never handles the user's IdP password. It redirects the browser to Go Loose, receives a short-lived one-time authorization code, exchanges that code from its backend using a client secret, and stores the returned opaque session in an HTTP-only cookie.
## 1. Configure the application in Go Loose
1. Open **Applications** and choose **Configure client login**.
2. Add the exact callback URL, for example `https://nmbs.guess.local/auth/callback`.
3. Save and copy the client secret. It is shown once.
4. Open **Users**, invite the user by the email returned by the IdP, and choose **Manage** to grant access to the application.
For database login, the invitation also requires an initial password. The same tenant login screen supports both database credentials and SSO.
Saving client configuration rotates the secret. Callback URLs require HTTPS, except `localhost` and `127.0.0.1` while Go Loose development login is enabled.
## 2. Add the Go client
```bash
go get github.com/danyel/go-loose/client
```
Configure the hosted login and register its handlers:
```go
package main
import (
"fmt"
"log"
"net/http"
"os"
"time"
goloose "github.com/danyel/go-loose/client"
)
func main() {
auth, err := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
BaseURL: "https://nmbs.auth.example.com",
ClientID: os.Getenv("GO_LOOSE_CLIENT_ID"),
ClientSecret: os.Getenv("GO_LOOSE_CLIENT_SECRET"),
RedirectURL: "https://nmbs.guess.local/auth/callback",
AfterLoginURL: "/app",
AfterLogoutURL: "/",
LoginPath: "/login",
CookieSecure: true,
HTTPClient: &http.Client{Timeout: 3 * time.Second},
})
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /login", auth.LoginHandler)
mux.HandleFunc("GET /auth/callback", auth.CallbackHandler)
mux.HandleFunc("POST /logout", auth.LogoutHandler)
protected := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := goloose.UserFromContext(r.Context())
if !ok {
http.Error(w, "user missing", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "Hello %s from tenant %s", user.DisplayName, user.TenantSlug)
}))
mux.Handle("GET /app", protected)
mux.Handle("GET /api/profile", protected)
log.Fatal(http.ListenAndServe(":8090", mux))
}
```
Keep `GO_LOOSE_CLIENT_SECRET` in a secret manager, never source control. In local development, configure an exact callback such as `http://localhost:8090/auth/callback` and set `CookieSecure: false`.
## Tenant login hostname
Each tenant has its own login origin:
```text
https://<tenant-slug>.<GO_LOOSE_AUTH_DOMAIN>
```
For tenant `nmbs` with `GO_LOOSE_AUTH_DOMAIN=auth.local`, configure the Go client with:
```go
BaseURL: "http://nmbs.auth.local:18080"
```
For local development, add the tenant hostname to `/etc/hosts`:
```text
127.0.0.1 nmbs.auth.local
```
Production should configure wildcard DNS and TLS for `*.auth.example.com`, route those hosts to Go Loose, and use:
```dotenv
GO_LOOSE_BASE_URL=https://auth.example.com
GO_LOOSE_AUTH_DOMAIN=auth.example.com
GO_LOOSE_OIDC_REDIRECT_URL=https://auth.example.com/auth/callback
```
The client package initially redirects to `/connect/authorize`. Go Loose verifies the registered client and exact callback, canonicalizes the browser onto the tenant hostname, and renders `<Tenant name> login` for the requested application.
## 3. Browser flow
```text
Browser → GET /login on client application
→ GET /connect/authorize on Go Loose
→ separate Go Loose client login
→ organization OIDC provider
→ Go Loose validates user + application grant
→ client /auth/callback?code=...&state=...
Client → POST /connect/token with Authorization: Basic <client-id:secret>
← opaque 12-hour session token
Browser ← HTTP-only SameSite session cookie on client application
Request → client middleware → GET /connect/userinfo → protected handler
Authorization: Bearer <opaque-session>
```
The authorization code expires after two minutes and can be used once. Redirect URIs must match exactly. Removing a user's application grant immediately makes `/connect/userinfo` reject existing sessions. Logout revokes the central session and clears the local cookie.
## Security and availability
- Use TLS for both Go Loose and the client application.
- Keep client secrets server-side and rotate them after suspected disclosure.
- Do not replace the middleware's fail-closed behavior with an allow-on-error fallback.
- Apply CSRF protection to state-changing routes in the client application; the package handles login `state`, not your application forms.
- The middleware checks Go Loose on every request for immediate revocation. Deploy Go Loose highly available and use a bounded HTTP timeout.
- API-key authentication and browser-user authentication are independent. Use `client.Middleware` for service/API keys and `BrowserAuth.Middleware` for logged-in users.
## Troubleshooting `403`
`403 your account does not have access to this application` means authentication succeeded but the user lacks an application grant. In Go Loose, open **Users**, select the user, choose **Manage**, and enable the target application. A missing or incorrect token header returns `401`, not `403`.

46
docs/coding-standards.md Normal file
View file

@ -0,0 +1,46 @@
# Coding standards
## Go
- Use the standard library unless a dependency clearly reduces security or protocol risk.
- Keep public APIs small, documented, and backward compatible.
- Pass `context.Context` through I/O boundaries and always bound network calls with timeouts.
- Wrap errors with the operation; never log secrets, cookies, authorization headers, or full API keys.
- Return explicit errors. Runtime authorization must fail closed.
- Keep SQL tenant-aware: management queries must join through membership, and writes must enforce role in the query or transaction.
- Use cryptographically secure randomness for credentials and constant-time comparison for signatures.
- Format with `gofmt`; run `go test -race ./...`, `go vet ./...`, and `go build ./...`.
## HTTP API
- Routes are versioned under `/api/v1`; health and authentication routes are infrastructure exceptions.
- JSON handlers reject unknown fields, extra JSON values, and oversized bodies.
- Errors use `{"error":"human-readable message"}` without internal implementation details.
- Use `401` for absent/unusable authentication, `403` for authenticated credentials outside policy, `404` when existence must not disclose access, and `422` for semantically invalid contracts.
- Any management mutation requires a valid user session and same-origin browser request.
- Update `web/openapi.json` whenever endpoint behavior changes.
## Database
- Use UUID primary keys for tenant data and UTC `timestamptz` timestamps.
- Foreign keys declare deletion behavior; frequently queried authorization fields get explicit indexes.
- Applied migrations are immutable and forward-compatible.
- Multi-step writes use transactions. Do not add application-side checks that can race when a database constraint can enforce the invariant.
## Frontend
- Preserve keyboard access, semantic elements, visible focus, responsive layout, and sufficient contrast in every theme.
- Escape all server-provided strings before assigning HTML. Prefer `textContent` for new dynamic surfaces.
- Store only visual preferences in local storage; authentication remains in HTTP-only cookies.
- Keep the console dependency-free unless complexity justifies a reviewed build tool decision.
## Tests and reviews
- New domain behavior needs table-driven unit tests and negative cases.
- Security boundaries need explicit tests for missing, malformed, expired, cross-tenant, and unauthorized inputs.
- Integration tests that need PostgreSQL should use a disposable database and never shared developer data.
- Reviews prioritize tenant isolation, fail-closed behavior, migration compatibility, secret handling, and error visibility.
## Commit style
Use focused commits with imperative subjects, for example `Add scoped API key authorization`. Do not mix unrelated refactors with behavior changes. Explain migration and security implications in the commit body.

75
docs/installation.md Normal file
View file

@ -0,0 +1,75 @@
# First-run installation and demo data
## Clean installation
Start Go Loose with PostgreSQL and a stable `GO_LOOSE_SESSION_SECRET` of at least 32 characters. No OIDC environment variables or bootstrap account are required:
```bash
docker compose up --build
```
Open `http://localhost:8080/install`. The installer asks for a Google OAuth client ID and client secret. In Google Cloud Console, configure this authorized redirect URI before submitting:
```text
http://localhost:8080/auth/callback
```
The supplied **CMS Demo** client ID is prefilled:
```text
554628171917-forvpfs44pqajv77dfhl503prfqnhor3.apps.googleusercontent.com
```
The Google client secret is not included in the repository and must be copied from Google Cloud Console. Go Loose validates Google OIDC discovery, encrypts the secret with AES-GCM using a key derived from `GO_LOOSE_SESSION_SECRET`, and stores only the ciphertext in PostgreSQL. Keep the session secret stable: losing it makes the installed OIDC secret undecryptable.
Installation is a one-time operation. Submitting the installer immediately starts Google login. Google redirects to `/auth/callback`, where Go Loose validates the ID token, creates a signed HTTP-only session cookie, and redirects to the management console. Browser redirects cannot carry a caller-defined `Authorization` header, so no bearer token is exposed in the URL or to frontend JavaScript.
Later requests to `/install` redirect to login, and duplicate submissions are rejected.
## First administrator and waiting room
The first user who completes Google SSO becomes the system administrator. A PostgreSQL advisory transaction lock makes this first-user claim atomic when multiple users sign in concurrently. The system administrator receives owner access to every tenant and application that exists at that moment.
Later first-time SSO users are created without tenant membership. Their management page shows only the waiting room. A system administrator approves a waiting user from **Users & access**, selecting:
- the tenant;
- tenant role (`viewer`, `admin`, or `owner`);
- the applications the user may log in to.
Tenant owners and administrators can manage users, applications, API keys, client login, and OpenAPI contracts only inside their assigned tenants. A user assigned to multiple tenants can switch the active tenant from the console header. Database membership checks—not the selector—enforce isolation.
## Optional Guess demonstration seed
The installer selects the demonstration seed by default. It creates the `nmbs` and `ypto` tenants, one `guess` application in each tenant, and these local database users:
| Tenant | Email | Password | Role |
|---|---|---|---|
| NMBS | `interview@nmbs.auth.local` | `admin123` | Tenant administrator |
| YPTO | `interview@ypto.auth.local` | `admin123` | Tenant administrator |
| NMBS | `reviewer@nmbs.auth.local` | `Demo-Nmbs-2026!` | Tenant administrator |
| YPTO | `reviewer@ypto.auth.local` | `Demo-Ypto-2026!` | Tenant administrator |
All four users receive access only to their tenant's Guess application. Passwords are stored as Argon2id hashes; plaintext credentials are never written to PostgreSQL.
These accounts and especially `admin123` are intentionally non-production credentials. Disable the seed for production, or delete/rotate every demo account before exposing the service.
For local hosted-login testing, add:
```text
127.0.0.1 nmbs.auth.local ypto.auth.local
```
The tenant login origins are then:
```text
http://nmbs.auth.local:8080
http://ypto.auth.local:8080
```
An application must first have exact redirect URIs and a generated client secret configured in the management console before `/connect/authorize` can start its login flow.
## Environment-configured compatibility mode
Existing deployments may continue to supply all three `GO_LOOSE_OIDC_ISSUER`, `GO_LOOSE_OIDC_CLIENT_ID`, and `GO_LOOSE_OIDC_CLIENT_SECRET` variables. Go Loose treats that as an already configured deployment and bypasses the installer. Supplying `GO_LOOSE_LOCAL_LOGIN=true` together with a non-empty `GO_LOOSE_LOCAL_ADMIN_PASSWORD` does the same for a database-only development deployment.
For new installations, prefer the web installer. `GO_LOOSE_LOCAL_LOGIN=true` without a bootstrap password enables seeded and invited database users but does not bypass installation.

64
docs/integration.md Normal file
View file

@ -0,0 +1,64 @@
# Integrating a Go API
Install the module from its eventual repository location:
```bash
go get github.com/danyel/go-loose/client
```
Wrap a router or selected route group:
```go
package main
import (
"log"
"net/http"
"time"
goloose "github.com/danyel/go-loose/client"
)
func main() {
access, err := goloose.New(goloose.Config{
BaseURL: "https://nmbs.goloose.local",
Tenant: "nmbs",
Application: "guess",
HTTPClient: &http.Client{Timeout: 2 * time.Second},
})
if err != nil {
log.Fatal(err)
}
protected := access.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, _ := goloose.PrincipalFromContext(r.Context())
w.Write([]byte("authorized by key " + principal.APIKeyName))
}))
mux := http.NewServeMux()
mux.Handle("GET /api/guesses", protected)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
log.Fatal(http.ListenAndServe(":8090", mux))
}
```
Call the protected API:
```bash
curl -H 'X-API-Key: gl_REPLACE_WITH_ISSUED_SECRET' \
https://nmbs.guess.local/api/guesses
```
The middleware sends the original method, URL path, and request host to Go Loose. It denies missing/invalid keys and control-plane errors, then removes `X-API-Key` before invoking the application handler. Read the authorized tenant/application/key identity with `PrincipalFromContext`.
## Importing the service contract
Expose an OpenAPI JSON or YAML route from the service, then use **Contracts → Import contract → Scan URL**. Private/internal URLs are blocked by default; production deployments that need internal discovery should use a restricted egress network and explicitly set `GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS=true`.
Contracts can also be pasted into the console, which avoids server-side egress entirely.
## Availability and latency
The current client validates remotely on every request so revocation is immediate. Run Go Loose highly available and set a strict client timeout. Do not implement an “allow on error” fallback. A future signed-assertion mode can reduce latency while retaining bounded revocation delay.

78
docs/operations.md Normal file
View file

@ -0,0 +1,78 @@
# Operations
## First-run installer
New deployments need only PostgreSQL, the public URL settings, and a stable session secret. Open `/install` to configure Google SSO and optional Guess demo data. See [First-run installation and demo data](installation.md).
The first Google SSO user is claimed as system administrator. All later SSO users remain in the waiting room until that administrator grants tenant membership and application access.
## OIDC / SSO
Register an OIDC confidential web client with this callback:
```text
https://<go-loose-host>/auth/callback
```
The web installer is preferred. Environment configuration remains available for existing or fully automated deployments:
```dotenv
GO_LOOSE_BASE_URL=https://nmbs.goloose.local
GO_LOOSE_AUTH_DOMAIN=auth.example.com
GO_LOOSE_OIDC_ISSUER=https://id.example.com/realms/platform
GO_LOOSE_OIDC_CLIENT_ID=go-loose
GO_LOOSE_OIDC_CLIENT_SECRET=<from-secret-manager>
GO_LOOSE_OIDC_REDIRECT_URL=https://nmbs.goloose.local/auth/callback
GO_LOOSE_ALLOWED_EMAIL_DOMAINS=example.com
GO_LOOSE_DEV_LOGIN=false
```
Client applications are served on `https://<tenant>.auth.example.com`; configure wildcard DNS and TLS for `*.auth.example.com`. The OIDC callback host and `GO_LOOSE_BASE_URL` must be inside the configured auth domain so the callback can issue the shared tenant-login cookie.
Database authentication is controlled by `GO_LOOSE_LOCAL_LOGIN`. It may remain enabled for seeded or invited users without creating a bootstrap account. Set `GO_LOOSE_LOCAL_ADMIN_PASSWORD` only when compatibility bootstrap behavior is explicitly required, and provide it through a secret manager.
The provider must return `sub`, `email`, and preferably `name` claims.
## Migrations
Migration files live in `internal/database/migrations` and are embedded into the binary. Files run lexicographically once, in individual transactions.
Create upgrades with an immutable numbered file:
```text
0002_membership_invitations.sql
```
Use this structure:
```sql
-- +goose Up
CREATE TABLE membership_invitations (...);
-- +goose Down
DROP TABLE membership_invitations;
```
The built-in runner applies only the `Up` section. Down sections document rollback intent but are never run automatically. Never edit an applied migration; create a new one. Prefer backward-compatible expansion first, deploy code that understands both shapes, then remove old fields in a later release.
Back up the database before production upgrades. Restore and test backups regularly. Startup fails if a migration fails, preventing the application from running against a partially upgraded schema.
## Production checklist
- TLS terminates at a trusted ingress and forwards the original host.
- `GO_LOOSE_BASE_URL` is the exact HTTPS browser origin.
- Session and OIDC secrets come from a secret manager.
- Demo users are not seeded, or every demo credential is removed/rotated before exposure.
- Development login and unrestricted private URL scanning are disabled.
- PostgreSQL uses TLS, backups, point-in-time recovery, and least-privilege credentials.
- `/healthz` is used for readiness; logs are shipped without request headers.
- Authorization endpoint rate limits and network policies are configured at ingress.
- Multiple replicas share PostgreSQL; rolling deployment starts only after migration compatibility is verified.
- The external Swagger UI assets are mirrored locally if internet-free operation is required.
## Key incident response
1. Revoke the exposed key in the console.
2. Issue a replacement and update the consumer through its secret manager.
3. Review `authorization_events` for the key ID and affected time window.
4. Investigate the exposure source; never restore a revoked key.

View file

@ -0,0 +1,56 @@
# Database user authentication
Go Loose supports local email/password accounts stored in PostgreSQL. Passwords are never stored in plaintext: the server uses Argon2id with a random 16-byte salt, 64 MiB of memory, three iterations, and two lanes.
## Seeded Docker credentials
On a fresh Compose deployment, use `/install` and keep **Seed NMBS and YPTO Guess demo data** selected. The complete credential table is in the [installation guide](installation.md). The two primary interview accounts are:
```text
interview@nmbs.auth.local / admin123
interview@ypto.auth.local / admin123
```
For tenant application login, add `127.0.0.1 nmbs.auth.local ypto.auth.local` to `/etc/hosts`.
```text
http://nmbs.auth.local:8080
```
If alternate ports are needed:
```bash
GO_LOOSE_PORT=18080 \
GO_LOOSE_POSTGRES_PORT=15432 \
docker compose up --build
```
Then use `http://localhost:18080/login` and `http://nmbs.auth.local:18080`.
## Configuration
```dotenv
GO_LOOSE_LOCAL_LOGIN=true
GO_LOOSE_LOCAL_ADMIN_EMAIL=admin@example.com # optional bootstrap
GO_LOOSE_LOCAL_ADMIN_PASSWORD=<at-least-12-characters> # optional bootstrap
```
`GO_LOOSE_LOCAL_LOGIN=true` enables all database-backed users. A bootstrap account is created only when `GO_LOOSE_LOCAL_ADMIN_PASSWORD` is non-empty; its password is applied only when the account has no password hash. Changing the environment variable later does not silently overwrite an existing database password. If a bootstrap account is required, inject its password from a secret manager and never document a production credential.
## Creating client users
1. Sign in to the management console.
2. Open **Users** and choose **Invite user**.
3. Enter the user's email, display name, initial password, and tenant role.
4. Choose **Manage** for that user and grant the required applications.
5. Give the initial credential to the user through a secure channel.
The user can then sign in on `<tenant>.auth.local` or the production tenant auth hostname. Re-inviting an existing email explicitly replaces its password and display name.
## Brute-force and audit behavior
- Five failed attempts for the same IP/email pair within ten minutes cause a fifteen-minute lock.
- Invalid emails and invalid passwords return the same response.
- Unknown-user attempts still run Argon2id verification to reduce timing differences.
- Successful, failed, and rate-limited attempts are written to `user_login_events`.
- Authentication sessions remain signed HTTP-only cookies. Tenant client-login sessions cannot access the management console.