aRustyDev / aRustyDev/mdbook-htmx

docs(adr): ADR-0003: Authorization Metadata via Manifest

Open
#11 0 comments 0 reactions 1 assignee Claimed by @aRustyDev View on GitHub
documentation
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.