Skip to content

Commit 00afd8a

Browse files
committed
docs: add infrastructure route tier, API standardization roadmap, and updated docs
AGENTS.md: - Add infrastructure route tier (healthz, readyz, metrics) — any domain, no auth - Add /api/v1/docs to public (any domain) tier - Renumber all tiers (1-6) roadmap.md: - Add "Standardized API Response Envelope & Pagination" — Critical - Add "Request Trace IDs" — Critical - Add "Structured Error Codes" — Critical - Consolidate API pagination into response envelope item - Document current response inconsistencies, proposed format, migration strategy architecture.md, deployment.md, api-reference.md, README.md: - Update docs to reflect base-domain auth, health endpoints, metrics, logging config
1 parent 46fbd7d commit 00afd8a

6 files changed

Lines changed: 606 additions & 41 deletions

File tree

AGENTS.md

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Migrations are versioned and tracked in `common.schema_migrations`.
5959
- **Middleware chain (users):** `Auth(jwt) → TenantResolver(subdomain/header + membership check) → Handler`
6060
- **Password hash** is tagged `json:"-"` on the User model — never exposed in API responses.
6161
- **Email enumeration** is prevented: login returns the same error for wrong email and wrong password.
62+
- **Base domain restriction:** When `AEGIS_BASE_DOMAIN` is set, all public auth endpoints (register, login, logout, forgot-password, reset-password, MFA validate, verify-email) are blocked on org subdomains via `baseOnlyMiddleware`. Auth flows must happen on the base domain only. Cookies are set with `Domain=.baseDomain` for cross-subdomain sharing. The UI redirects users from subdomain auth pages to the base domain with a `?return_to=` param.
6263

6364
### Agent Authentication
6465

@@ -143,6 +144,13 @@ Each org tracks a `schema_version` in `common.organizations`. This allows:
143144
- **Context:** Use `middleware.UserFromContext(ctx)` and `middleware.OrgFromContext(ctx)` to access the authenticated user and current org.
144145
- **Tenant store:** Use `tenantStore(r)` helper in handlers (calls `middleware.TenantStoreFromContext`).
145146
- **Imports:** Group as stdlib → external → internal.
147+
- **Logging:** Use `log/slog` (Go stdlib). Never use `fmt.Printf`, `log.Printf`, or `log.Println` for application logging.
148+
- `slog.Info("message", "key", value)` — normal operational events
149+
- `slog.Warn("message", "key", value)` — degraded but recoverable situations
150+
- `slog.Error("message", "error", err)` — failures that need attention
151+
- `slog.Debug("message", "key", value)` — verbose tracing (only visible at debug level)
152+
- Always include structured key-value pairs, not formatted strings
153+
- For request-scoped logging, use `logger.FromContext(ctx)` (future OTel trace ID injection)
146154

147155
### React UI
148156

@@ -214,10 +222,12 @@ ci: add Docker build verification to CI
214222

215223
### Route Tiers
216224

217-
1. **Public** — no auth required: `/api/v1/auth/register`, `/api/v1/auth/login`, `/api/v1/auth/logout`
218-
2. **Authenticated** — JWT cookie required: `/api/v1/auth/me`, `/api/v1/orgs`, `/api/v1/config/features`
219-
3. **Protected** — JWT + org context (subdomain/X-Org-Slug + membership verified): findings, projects, members, tokens, dashboard
220-
4. **Agent** — Bearer token (org resolved from subdomain/header, no membership check): `/api/v1/agent/*`
225+
1. **Infrastructure (any domain, no auth)** — registered on the top-level mux outside the API server, bypasses all middleware. Accessible on base domain, org subdomains, custom domains, IPs — anywhere. Used by load balancers, Kubernetes probes, and monitoring systems: `/healthz`, `/readyz`, `/metrics`
226+
2. **Public (base domain only)** — no auth required, but blocked on org subdomains when `AEGIS_BASE_DOMAIN` is set: `/api/v1/auth/register`, `/api/v1/auth/login`, `/api/v1/auth/logout`, `/api/v1/auth/forgot-password`, `/api/v1/auth/reset-password`, `/api/v1/auth/mfa/validate`, `/api/v1/auth/verify-email`
227+
3. **Public (any domain)** — no auth, works everywhere: `/api/v1/config/auth`, `/api/v1/docs`, `/api/v1/docs/openapi.yaml`
228+
4. **Authenticated** — JWT cookie required: `/api/v1/auth/me`, `/api/v1/orgs`, `/api/v1/config/features`
229+
5. **Protected** — JWT + org context (subdomain/X-Org-Slug + membership verified): findings, projects, members, tokens, dashboard
230+
6. **Agent** — Bearer token (org resolved from subdomain/header, no membership check): `/api/v1/agent/*`
221231

222232
### Request/Response
223233

@@ -335,6 +345,15 @@ Transactional emails (password reset, MFA codes, etc.) are sent via SMTP. Config
335345

336346
**Production:** Set real SMTP credentials (SendGrid, AWS SES, Mailgun, etc.) in `.env`.
337347

348+
### Logging
349+
350+
| Variable | Default | Description |
351+
|---|---|---|
352+
| `LOG_LEVEL` | `info` | Minimum log level: `debug`, `info`, `warn`, `error` |
353+
| `LOG_FORMAT` | `text` | Output format: `text` (human-readable) or `json` (structured for log aggregation) |
354+
355+
Uses Go's stdlib `log/slog`. JSON format is recommended for production with log aggregation (ELK, Loki, CloudWatch, etc.).
356+
338357
---
339358

340359
## Testing

README.md

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,43 @@ npm run dev
4747
# Open http://localhost:5173
4848
```
4949

50+
### Subdomain Testing (local)
51+
52+
Aegis supports subdomain-based org resolution (e.g., `acme.aegis.io`). To test this locally, we use [`lvh.me`](http://lvh.me) — a public domain where `*.lvh.me` resolves to `127.0.0.1`. No `/etc/hosts` changes needed.
53+
54+
**Setup:**
55+
56+
```bash
57+
# .env (already set by default in .env.example)
58+
AEGIS_BASE_DOMAIN=lvh.me
59+
60+
# Restart
61+
docker compose up --build -d
62+
```
63+
64+
**Usage:**
65+
66+
| URL | What happens |
67+
|---|---|
68+
| `http://lvh.me:8080` | Base domain — no subdomain, header-only mode |
69+
| `http://test.lvh.me:8080` | Resolves org with slug `test` from subdomain |
70+
| `http://acme.lvh.me:8080` | Resolves org with slug `acme` from subdomain |
71+
| Any new org slug | Works instantly — `*.lvh.me` is a wildcard |
72+
73+
**Testing the mismatch guard:**
74+
75+
```bash
76+
# This works — subdomain matches
77+
curl -b cookies.txt http://test.lvh.me:8080/api/v1/findings
78+
79+
# This is REJECTED (400) — subdomain says "test" but header says "acme"
80+
curl -b cookies.txt http://test.lvh.me:8080/api/v1/findings \
81+
-H "X-Org-Slug: acme"
82+
# → {"error":"X-Org-Slug header conflicts with subdomain"}
83+
```
84+
85+
> **Production:** Set `AEGIS_BASE_DOMAIN=aegis.io` (or your domain). Configure DNS with a wildcard `*.aegis.io → your-server-ip`.
86+
5087
---
5188

5289
## Architecture
@@ -120,6 +157,15 @@ aegis/
120157
│ │ └── dashboard.go # Dashboard stats
121158
│ ├── auth/ # JWT + bcrypt service
122159
│ ├── config/ # Env-based configuration
160+
│ ├── email/
161+
│ │ ├── email.go # SMTP transport service
162+
│ │ └── templates/ # HTML email templates
163+
│ │ ├── layout.go # Shared base layout + helpers
164+
│ │ ├── login_alert.go # New sign-in notification
165+
│ │ ├── password_reset.go # Reset link email
166+
│ │ ├── password_changed.go # Change confirmation
167+
│ │ ├── verify_email.go # Email verification link
168+
│ │ └── mfa_code.go # MFA OTP code
123169
│ ├── middleware/
124170
│ │ ├── auth.go # JWT cookie validation
125171
│ │ ├── tenant.go # Org resolution + membership check
@@ -153,7 +199,7 @@ aegis/
153199
│ └── components/
154200
│ ├── app-sidebar.tsx # Left nav + user menu
155201
│ ├── top-nav.tsx # Breadcrumb header
156-
│ ├── org-project-switcher.tsx
202+
│ ├── org-switcher.tsx
157203
│ ├── metric-card.tsx # Dashboard stat cards
158204
│ ├── severity-badge.tsx
159205
│ └── ui/ # shadcn/ui primitives
@@ -198,7 +244,7 @@ All configuration is via environment variables:
198244
| Method | Endpoint | Description |
199245
|---|---|---|
200246
| POST | `/api/v1/orgs` | Create org (creator = owner) |
201-
| GET | `/api/v1/orgs` | List user's orgs |
247+
| GET | `/api/v1/orgs` | List user's orgs + `base_domain` config |
202248
| GET | `/api/v1/orgs/{slug}` | Get org by slug |
203249

204250
### Feature Flags (authenticated)

docs/api-reference.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,21 @@ Current flags: `signup`, `invite_only`, `scan_docker_mode`, `public_api`
617617

618618
---
619619

620+
### GET `/config/auth`
621+
622+
Returns auth configuration. **Public endpoint** — no authentication required. Used by the UI to detect subdomain mode before the user has a session.
623+
624+
**Response (200):**
625+
```json
626+
{
627+
"base_domain": "aegis.io"
628+
}
629+
```
630+
631+
When `AEGIS_BASE_DOMAIN` is not set, `base_domain` is an empty string.
632+
633+
---
634+
620635
## Scans 🔒🏢 (Read-Only)
621636

622637
Requires org context (`X-Org-ID` or `X-Org-Slug` header).

docs/architecture.md

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,10 @@ This is validated by regex `^org_[a-f0-9]{32}$` to prevent SQL injection.
9999
│ └─ Inject user into context
100100
101101
5. Tenant Middleware (for org-scoped routes):
102-
│ ├─ Read subdomain from Host header (production)
103-
│ ├─ Or read X-Org-ID / X-Org-Slug header (dev)
102+
│ ├─ Try subdomain from Host header (production, if AEGIS_BASE_DOMAIN set)
103+
│ ├─ Try custom domain lookup (if host ≠ base domain)
104+
│ ├─ Reject if X-Org-* header conflicts with subdomain (400)
105+
│ ├─ Fallback: X-Org-ID / X-Org-Slug header (dev mode)
104106
│ ├─ Load org from DB
105107
│ ├─ Verify user is a member of the org
106108
│ ├─ Create schema-scoped Store
@@ -130,9 +132,32 @@ This is validated by regex `^org_[a-f0-9]{32}$` to prevent SQL injection.
130132

131133
## Auth Flow
132134

135+
### Base Domain Restriction
136+
137+
When `AEGIS_BASE_DOMAIN` is set (e.g., `aegis.io`), all auth flows are restricted to the base domain only. Requests to org subdomains (e.g., `acme.aegis.io/api/v1/auth/login`) are rejected with a 403 error.
138+
139+
This applies to: register, login, logout, forgot-password, reset-password, MFA validate, MFA send-email-otp, and verify-email endpoints.
140+
141+
**Defense in depth:**
142+
- **Server-side:** `baseOnlyMiddleware` rejects auth API calls from any host that isn't the exact base domain (subdomains, IPs, unknown hostnames are all blocked)
143+
- **UI-side:** `useSubdomainAuthRedirect` hook redirects auth pages to the base domain
144+
- **Cookie scoping:** Auth cookies are set with `Domain=.aegis.io` so they work across all subdomains
145+
146+
**Login redirect flow:**
147+
```
148+
User visits acme.aegis.io (no session)
149+
→ UI redirects to aegis.io/login?return_to=https://acme.aegis.io/
150+
→ User logs in on aegis.io (cookie set with Domain=.aegis.io)
151+
→ UI redirects back to acme.aegis.io/ (cookie is valid on subdomain)
152+
```
153+
154+
The `return_to` parameter is validated to prevent open redirect attacks — only URLs sharing the same base domain are allowed.
155+
156+
**Exceptions:** `GET /api/v1/auth/me` works on any subdomain (it's authenticated, not public, and the UI needs it to check session status).
157+
133158
### Registration
134159
```
135-
POST /api/v1/auth/register
160+
POST /api/v1/auth/register (base domain only when AEGIS_BASE_DOMAIN set)
136161
137162
├─ Check feature flag: signup enabled?
138163
├─ Validate email, password (8+ chars, upper+lower+digit), name
@@ -142,22 +167,23 @@ POST /api/v1/auth/register
142167
├─ Create default org ("Name's Org")
143168
├─ Add user as org owner
144169
├─ Generate JWT (24h TTL)
145-
└─ Set aegis_token HttpOnly cookie
170+
└─ Set aegis_token HttpOnly cookie (Domain=.baseDomain when set)
146171
```
147172

148173
### Login
149174
```
150-
POST /api/v1/auth/login
175+
POST /api/v1/auth/login (base domain only when AEGIS_BASE_DOMAIN set)
151176
152177
├─ Find user by email
153178
├─ Compare bcrypt hash (same error for wrong email/password)
179+
├─ If MFA enabled: return mfa_required + short-lived MFA token
154180
├─ Generate JWT
155-
└─ Set aegis_token HttpOnly cookie
181+
└─ Set aegis_token HttpOnly cookie (Domain=.baseDomain when set)
156182
```
157183

158184
### Session Check
159185
```
160-
GET /api/v1/auth/me
186+
GET /api/v1/auth/me (works on any subdomain)
161187
162188
├─ Auth middleware validates cookie
163189
├─ Load user + their orgs
@@ -190,5 +216,7 @@ Each org's data is fully isolated at the database level:
190216
| Auth (Users) | bcrypt (cost 12), JWT (HS256, golang-jwt/v5) |
191217
| Auth (Agents) | Bearer tokens, bcrypt-hashed, per-org schema |
192218
| Org Resolution | Subdomain (`acme.aegis.io`) or `X-Org-Slug` header |
219+
| Logging | `log/slog` (Go stdlib), configurable level/format via `LOG_LEVEL`/`LOG_FORMAT` |
220+
| Observability | OpenTelemetry metrics, Prometheus exporter (`/metrics`) |
193221
| Deployment | Docker, Docker Compose |
194222
| UI Embedding | Go `embed` package (SPA served from binary) |

docs/deployment.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,16 @@ POSTGRES_DB=aegis
2828
AEGIS_PORT=8080
2929
AEGIS_ALLOWED_ORIGINS=https://your-domain.com
3030

31-
# Subdomain-based org resolution (optional)
32-
# Set this to enable acme.aegis.io style org URLs
33-
# AEGIS_BASE_DOMAIN=aegis.io
31+
# Subdomain-based org resolution
32+
# Development: lvh.me (*.lvh.me resolves to 127.0.0.1)
33+
# Production: your-domain.com (configure wildcard DNS: *.your-domain.com)
34+
AEGIS_BASE_DOMAIN=lvh.me # dev
35+
# AEGIS_BASE_DOMAIN=aegis.io # production
36+
37+
# Logging (default: info level, text format)
38+
# Use json format for production log aggregation (ELK, Loki, etc.)
39+
LOG_LEVEL=info
40+
LOG_FORMAT=text
3441
```
3542

3643
Generate a strong JWT secret:

0 commit comments

Comments
 (0)