aRustyDev / aRustyDev/mdbook-htmx
docs(adr): ADR-0003: Authorization Metadata via Manifest
- Dominant language
- Rust
- Stars
- 0
- Forks
- 1
- PR merge metrics
- No merged PRs in 30d
Description
# ADR-0003: Authorization Metadata via Manifest
## Status
Accepted
## Context
The skeleton files indicate a need for "authorization boundaries" - the ability to gate documentation sections based on user roles. This is common for:
- Internal documentation with tiered access
- Premium content behind paywalls
- Admin-only configuration guides
The backend must decide how to communicate authorization requirements to the server.
## Decision Drivers
1. **Separation of Concerns** - Build-time metadata, runtime enforcement
2. **Static Output** - Backend produces files, not runtime logic
3. **Server Agnostic** - Work with Workers, Node, Deno, etc.
4. **Developer Experience** - Simple frontmatter syntax
## Options Considered
### Option A: Inline HTML Markers
Embed auth requirements as data attributes in HTML:
```html
...
```
Server parses HTML to extract requirements.
**Pros:**
- Self-contained (no separate manifest)
- Works with any HTML parser
**Cons:**
- Requires HTML parsing on every request
- Scattered across files
- Hard to build routing table
### Option B: Separate Auth Config File
Central `auth.yaml` mapping paths to requirements:
```yaml
/docs/admin/*:
roles: [admin]
fallback: /access-denied
```
**Pros:**
- Centralized configuration
- Easy to audit
**Cons:**
- Divorced from content (can drift)
- Duplicate path definitions
- Not part of MDBook workflow
### Option C: Manifest with Auth Metadata (Recommended)
Generate `manifest.json` with per-page auth requirements extracted from frontmatter:
```markdown
---
auth:
roles: [admin, editor]
fallback: /docs/access-denied
---
```
Produces:
```json
{
"pages": [
{
"path": "/docs/admin/config",
"auth": {
"roles": ["admin", "editor"],
"fallback": "/docs/access-denied"
}
}
]
}
```
**Pros:**
- Auth lives with content (frontmatter)
- Single manifest for all routing metadata
- Easy to load at server startup
- No runtime parsing
**Cons:**
- Frontmatter must be parsed at build time
- Manifest must be kept in sync
### Option D: No Auth Support
Leave authorization entirely to server implementation.
**Pros:**
- Simpler backend
**Cons:**
- Every deployment reinvents the wheel
- Inconsistent patterns
## Decision
**Extract auth metadata from frontmatter into `manifest.json`.**
Rationale:
1. Frontmatter is the natural place for page metadata in MDBook
2. Manifest provides a single source of truth for server routing
3. JSON is universally parseable (Workers, Node, Deno, Python)
4. Build-time extraction means zero runtime overhead
5. Server loads manifest once at startup, O(1) lookups
## Frontmatter Schema
```yaml
auth:
access: public | authenticated | roles
roles: [role1, role2] # If access == "roles"
fallback: /path/to/fallback # Redirect for unauthorized
```
Default: `access: public`
## Manifest Schema
```json
{
"pages": [
{
"path": "/docs/chapter",
"title": "Chapter Title",
"file": "pages/chapter.html",
"fragment": "fragments/chapter.html",
"auth": {
"access": "roles",
"roles": ["admin", "editor"],
"fallback": "/docs/access-denied"
}
}
]
}
```
## Server Implementation Pattern
```typescript
// Load manifest at startup
const manifest = JSON.parse(await Deno.readTextFile("manifest.json"));
const pageIndex = new Map(manifest.pages.map(p => [p.path, p]));
// Request handler
app.get("/docs/*", async (req) => {
const page = pageIndex.get(req.path);
if (!page) return notFound();
// Check auth
if (page.auth.access === "roles") {
const user = await getUser(req);
if (!user?.roles.some(r => page.auth.roles.includes(r))) {
return isHtmx(req)
? html(accessDeniedFragment)
: redirect(page.auth.fallback);
}
}
// Serve content
return serveFile(isHtmx(req) ? page.fragment : page.file);
});
```
## External Authorization Provider Integration
The manifest declares **what** authorization is required, not **how** to verify it. This separation enables integration with any identity/authorization provider.
### Supported Provider Patterns
| Provider Type | Examples | Integration Point |
|---------------|----------|-------------------|
| **Relationship-based (ReBAC)** | AuthZED/SpiceDB, OpenFGA | Check permission via API |
| **Policy-based (ABAC)** | OPA, Cedar | Evaluate policy with context |
| **Directory-based** | AD/LDAP, Okta | Query group membership |
| **Token-based** | JWT, OAuth2 | Decode claims from token |
| **Session-based** | Cookie sessions | Lookup session store |
### Integration Examples
#### AuthZED / SpiceDB (ReBAC)
```typescript
import { v1 } from "@authzed/authzed-node";
const client = v1.NewClient(process.env.SPICEDB_TOKEN);
async function checkAccess(user: User, page: Page): Promise {
if (page.auth.access === "public") return true;
if (page.auth.access === "authenticated") return !!user;
// Check relationship: user has 'read' on document
const response = await client.checkPermission({
resource: { objectType: "document", objectId: page.path },
permission: "read",
subject: { objectType: "user", objectId: user.id }
});
return response.permissionship === v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION;
}
```
#### OpenFGA
```typescript
import { OpenFgaClient } from "@openfga/sdk";
const fga = new OpenFgaClient({ storeId: process.env.FGA_STORE_ID });
async function checkAccess(user: User, page: Page): Promise {
const { allowed } = await fga.check({
user: `user:${user.id}`,
relation: "can_read",
object: `doc:${page.path}`
});
return allowed;
}
```
#### LDAP / Active Directory
```typescript
import ldap from "ldapjs";
async function checkAccess(user: User, page: Page): Promise {
if (!page.auth.roles) return true;
// Query user's group membership from AD
const groups = await ldapClient.search(user.dn, {
filter: "(objectClass=group)",
scope: "sub"
});
// Check if user belongs to any required group
return page.auth.roles.some(role =>
groups.includes(`CN=${role},OU=Groups,DC=company,DC=com`)
);
}
```
#### JWT Claims
```typescript
import { jwtVerify } from "jose";
async function checkAccess(req: Request, page: Page): Promise {
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) return page.auth.access === "public";
const { payload } = await jwtVerify(token, publicKey);
if (page.auth.access === "authenticated") return true;
// Check roles claim against required roles
const userRoles = payload.roles as string[];
return page.auth.roles?.some(r => userRoles.includes(r)) ?? false;
}
```
### Manifest Role Mapping
The manifest uses abstract role names. Map them to provider-specific identifiers:
```typescript
// Role mapping configuration
const roleMapping = {
"admin": {
spicedb: "role:admin#member",
ldap: "CN=Admins,OU=Groups,DC=corp,DC=local",
jwt_claim: "admin"
},
"developer": {
spicedb: "role:developer#member",
ldap: "CN=Developers,OU=Groups,DC=corp,DC=local",
jwt_claim: "dev"
}
};
```
### Provider-Agnostic Middleware
```typescript
// Abstract authorization interface
interface AuthzProvider {
checkAccess(user: User | null, page: Page): Promise;
}
// Middleware uses configured provider
app.use("/docs/*", async (c, next) => {
const page = manifest.pages.find(p => p.path === c.req.path);
const user = await getUser(c.req); // From authn layer
if (!await authzProvider.checkAccess(user, page)) {
return isHtmx(c.req)
? c.html(accessDeniedFragment, 403)
: c.redirect(page.auth.fallback || "/auth/login");
}
await next();
});
```
## Consequences
### Positive
- Single source of truth for auth requirements
- Zero runtime parsing
- Works with any server framework
- Auditable (grep manifest for roles)
- **Provider-agnostic** - swap auth backends without changing content
### Negative
- Manifest must be regenerated on content changes
- Server must load manifest (memory overhead)
- **Role mapping** may be needed between manifest and provider
### Mitigation
- Incremental manifest updates (only changed pages)
- Document manifest loading patterns
- Provide role mapping configuration pattern
- Reference implementations for common providers
## Related ADRs
- [ADR-0008: Authentication/Authorization Separation](./0008-authentication-authorization-separation.md) - Separates `[output.htmx.authn]` and `[output.htmx.authz]` configuration
- [ADR-0009: Audience Scopes](./0009-audience-scopes.md) - Scope-based content filtering (distinct from authorization)
## References
- [MDBook Frontmatter Proposal](https://github.com/rust-lang/mdBook/issues/1758)
- [Next.js Middleware Auth Pattern](https://nextjs.org/docs/advanced-features/middleware)
- [AuthZED Documentation](https://docs.authzed.com/)
- [OpenFGA Documentation](https://openfga.dev/docs)
- [Zanzibar Paper (Google)](https://research.google/pubs/pub48190/)
Contributor guide
Assessment
This issue has not been assessed yet.