mono/packages/ui/docs/acl.md
2026-04-01 17:24:08 +02:00

663 lines
27 KiB
Markdown

# ACL Management — Endpoint-Level, Database-Driven
> **Status**: Proposal
> **Scope**: Server-side ACL enforcement, admin CRUD, OpenAPI-first endpoint registry, product & cost metadata
> **Out of scope (for now)**: Billing/invoicing, UI
---
## 1. Problem Statement
The platform has **two disconnected permission layers** and a hardcoded product config that don't compose:
| Layer | Mechanism | Granularity |
|-------|-----------|-------------|
| `auth.ts` middleware | `PublicEndpointRegistry`, `AdminEndpointRegistry` | Binary: public vs auth vs admin |
| `db-acl.ts` orchestrator | Per-resource backends (VFS file-based, category DB-backed) | Resource-level ACL entries |
Additionally, `products.ts` hardcodes `PRODUCT_ACTIONS` with per-endpoint `costUnits` — this should move to the database so products and their endpoints are fully manageable via admin CRUD.
**What's missing:**
- No way for admins to **manage endpoint access** from a database — everything is code-deployed
- No **group/tier** concept for API endpoints (e.g. "free users get 10 searches/day, pro users get 1000")
- No standard way to express "user X can create pages but not posts" or "group Y can use the AI image generator"
- The `resource_acl` table handles per-object ACL (who can view this category) but not per-endpoint ACL (who can call POST /api/pages)
- No mechanism to feed fine-grained permissions to the frontend (e.g. "hide the Create Page button for this user")
---
## 2. Design Goals
1. **Zero new tables** — Everything fits in the existing `resource_acl` table using new `resource_type` conventions.
2. **OpenAPI-first** — The canonical list of "things you can control" is the OpenAPI spec (`/doc`). Every registered route has a `path`, `method`, and `tags[]`. ACL rules reference these directly.
3. **Database-driven** — All ACL configuration lives in Postgres, manageable via admin API. No more code deploys to change who can do what.
4. **Groups with inheritance** — ACL groups (tiers) can inherit from parent groups. `pro` inherits everything from `free`, then adds more.
5. **Rate-limit tiers** — Each group can have per-endpoint rate limits. The rate limiter middleware reads these from the ACL tables.
6. **Capability surfacing** — A single API call returns the authenticated user's effective permissions, consumable by the frontend to show/hide UI elements.
7. **Backward compatible** — Existing `PublicEndpointRegistry` / `AdminEndpointRegistry` continue to work. The new system layers on top.
---
## 3. Data Model — `resource_acl` Convention Map
### 3.0 Existing table (unchanged)
```sql
create table public.resource_acl (
id uuid primary key default gen_random_uuid(),
resource_type text not null,
resource_id text not null,
resource_owner_id uuid references auth.users(id),
user_id uuid references auth.users(id) on delete cascade,
group_name text,
permissions text[] not null default '{}',
path text default '/',
meta jsonb default '{}',
log jsonb default '{}',
created_at timestamptz default now(),
updated_at timestamptz default now(),
constraint check_grantee check (
(user_id is not null and group_name is null) or
(user_id is null and group_name is not null) or
(user_id is null and group_name is null) -- definition-only rows
)
);
```
The `check_grantee` constraint already allows rows where **both** `user_id` and `group_name` are `NULL` — these are perfect for definition/config rows (products, endpoints, groups).
### 3.1 New `resource_type` conventions
All new concepts map to `resource_type` discriminators on the **same table**:
| Concept | `resource_type` | `resource_id` | `user_id` | `group_name` | `permissions` | `path` | `meta` |
|---------|----------------|---------------|-----------|-------------|---------------|--------|--------|
| **Product → Group rule** | `product-acl` | product slug: `places` | — | `pro` | `[]` | `/` | `{effect, rate_limit, rate_window}` — applies to ALL endpoints in product |
| **Product → User override** | `product-acl` | product slug: `places` | `uuid` | — | `[]` | `/` | `{effect, rate_limit, rate_window, reason}` |
| **Endpoint def** | `endpoint` | `GET:/api/pages` | — | — | `[]` | `/api/pages` | `{tag, summary, product, cost_units, cancellable, is_public, is_admin}` |
| **Endpoint → Group rule** | `endpoint-acl` | `GET:/api/pages` | — | `editor` | `['create','update']` | `/` | `{effect, rate_limit, rate_window}` |
| **Endpoint → User override** | `endpoint-acl` | `GET:/api/pages` | `uuid` | — | `['create']` | `/` | `{effect, rate_limit, rate_window, reason, granted_by, expires_at}` |
| **Group def** | `acl-group` | slug: `editor` | — | — | `[]` | `/` | `{name, description, parent, priority, is_default}` |
| **Group member** | `acl-group-member` | group slug: `editor` | `uuid` | — | `[]` | `/` | `{granted_by, expires_at}` |
| VFS ACL (existing) | `vfs` | folder id | `uuid` or — | group or — | perms | scoped path | — |
| Category ACL (existing) | `category` | category id | `uuid` or — | group or — | perms | `/` | — |
> **Product-level vs endpoint-level rules:** A `product-acl` rule is a shorthand for "all endpoints in this product". When both exist, endpoint-level rules (`endpoint-acl`) override product-level rules (`product-acl`). This means admins can set a blanket rule for a product and then override specific endpoints.
### 3.2 Column reuse rationale
| Column | Original purpose | Reused for |
|--------|-----------------|------------|
| `resource_type` | Discriminator (`vfs`, `category`) | New types: `product-acl`, `endpoint`, `endpoint-acl`, `acl-group`, `acl-group-member` |
| `resource_id` | Resource identifier | Product slug, endpoint key (`METHOD:path`), group slug |
| `path` | VFS scoped path | Product prefix (`/api/places`), endpoint URL path |
| `permissions` | VFS/category perms | Endpoint fine-grained perms (`create`, `update`, `delete`) |
| `group_name` | VFS group grant | ACL group slug for endpoint rules |
| `user_id` | VFS user grant | Group membership, user overrides |
| `meta` | Extensible metadata | All config: costs, rate limits, tags, descriptions |
| `resource_owner_id` | VFS folder owner | Not used for new types (NULL) |
| `log` | Audit trail | Audit trail for rule changes |
### 3.3 Required schema changes
Only **one new index** needed — the existing indexes already cover the primary lookups:
```sql
-- New: fast group membership lookups (user → which groups?)
create index if not exists idx_resource_acl_group_name
on resource_acl(group_name)
where group_name is not null;
-- New: fast resource_type filtering for the new conventions
create index if not exists idx_resource_acl_type_id
on resource_acl(resource_type, resource_id)
where resource_type in ('product-acl', 'endpoint', 'endpoint-acl', 'acl-group', 'acl-group-member');
```
### 3.4 Seeding
```sql
-- Default ACL groups
insert into resource_acl (resource_type, resource_id, meta) values
('acl-group', 'anonymous', '{"name":"Anonymous","priority":0,"is_default":false}'),
('acl-group', 'authenticated', '{"name":"Authenticated","priority":10,"is_default":true}'),
('acl-group', 'editor', '{"name":"Editor","priority":20,"is_default":false,"parent":"authenticated"}'),
('acl-group', 'admin', '{"name":"Admin","priority":100,"is_default":false,"parent":"editor"}');
```
### 3.5 Example rows
```
# ── Note: Products themselves are defined in a separate `products` table ──
# e.g., A row in the 'products' table where slug='places' and settings='{"default_cost_units": 1.0}'
# ── Product-level ACL ───────────────────────────────────────
# "free" group gets access to places product, 10 calls/day across ALL its endpoints
resource_type=product-acl resource_id=places group_name=free
meta={effect:"allow", rate_limit:10, rate_window:86400}
# "pro" group gets 1000 calls/day across the whole product
resource_type=product-acl resource_id=places group_name=pro
meta={effect:"allow", rate_limit:1000, rate_window:86400}
# ── Endpoint definition (auto-synced from OpenAPI) ──────────
resource_type=endpoint resource_id=GET:/api/places/search path=/api/places/search
meta={tag:"Places", summary:"Search places", product:"places", cost_units:1.0, cancellable:true}
# ── Endpoint-level ACL override ─────────────────────────────
# Override: the find-email endpoint is more expensive, tighter limit for free
resource_type=endpoint-acl resource_id=GET:/api/places/email/:id group_name=free
meta={effect:"allow", rate_limit:3, rate_window:86400}
# ── User override ───────────────────────────────────────────
# VIP user gets 5000/day on the full product
resource_type=product-acl resource_id=places user_id=<alice-uuid>
meta={effect:"allow", rate_limit:5000, rate_window:86400, reason:"VIP customer"}
# ── Content creation example ────────────────────────────────
resource_type=endpoint-acl resource_id=POST:/api/pages group_name=editor
permissions=[create] meta={effect:"allow"}
resource_type=endpoint-acl resource_id=PUT:/api/pages/:id group_name=editor
permissions=[update] meta={effect:"allow"}
resource_type=endpoint-acl resource_id=DELETE:/api/pages/:id group_name=editor
permissions=[] meta={effect:"deny"}
```
### ER Diagram (logical, single table)
```mermaid
erDiagram
resource_acl {
uuid id PK
text resource_type "product-acl | endpoint | endpoint-acl | acl-group | acl-group-member | vfs | category"
text resource_id "slug or METHOD:path"
uuid resource_owner_id FK "nullable"
uuid user_id FK "nullable"
text group_name "nullable"
text[] permissions
text path "URL prefix or scoped path"
jsonb meta "all config & metadata"
jsonb log "audit trail"
}
resource_acl ||--o{ resource_acl : "group → members (via resource_id match)"
resource_acl ||--o{ resource_acl : "group → rules (via group_name match)"
resource_acl ||--o{ resource_acl : "endpoint → rules (via resource_id match)"
```
---
## 4. OpenAPI Endpoint Sync
On server boot, the system auto-syncs the OpenAPI spec into `resource_acl`:
```
Boot flow:
1. app.doc31('/doc', ...) generates the OpenAPI spec
2. syncEndpointsFromOpenAPI() reads the spec
3. For each operation:
UPSERT into resource_acl where resource_type='endpoint' and resource_id='METHOD:/path'
Sets meta: {tag, summary, is_public, is_admin, product (by longest prefix match)}
4. Mark stale endpoints (in DB but not in spec) via meta.deprecated = true
```
### Product assignment logic
During sync, each endpoint's `path` is matched against product rows in the *separate `products` table* (via prefix matching). Longest prefix wins:
```
/api/places/search → matches product 'places' (by settings prefix or path convention)
/api/images/upload → matches product 'images'
```
### Source: `PublicEndpointRegistry` + `AdminEndpointRegistry`
The existing decorators (`Public()`, `Admin()`) already populate these registries. The sync step reads both to set `meta.is_public` and `meta.is_admin` flags.
---
## 5. Permission Resolution Algorithm
When a request hits the middleware:
```
1. Extract user from JWT (or 'anonymous' sentinel)
2. Load user's groups:
SELECT resource_id FROM resource_acl
WHERE resource_type = 'acl-group-member' AND user_id = $1
UNION
SELECT resource_id FROM resource_acl
WHERE resource_type = 'acl-group' AND (meta->>'is_default')::boolean = true
→ then walk parent chain via meta.parent
3. Match endpoint → product:
a. Find resource_type='endpoint' row matching the request path+method
b. Read meta.product to get the product slug
4. Load effective rules (two tiers):
a. Endpoint-level rules:
SELECT * FROM resource_acl
WHERE resource_type = 'endpoint-acl'
AND resource_id = $endpoint_key
AND (group_name = ANY($user_groups) OR user_id = $user_id)
b. Product-level rules (fallback if no endpoint-level match):
SELECT * FROM resource_acl
WHERE resource_type = 'product-acl'
AND resource_id = $product_slug
AND (group_name = ANY($user_groups) OR user_id = $user_id)
5. Apply priority ordering:
- User overrides (user_id set) beat group rules
- Endpoint-level rules beat product-level rules
- Higher-priority groups override lower-priority groups
- Explicit 'deny' overrides 'allow' at same priority
6. Resolve cost:
endpoint.meta.cost_units ?? settings.default_cost_units (from separate products table) ?? 0
7. Resolve rate limit:
endpoint-acl rule > product-acl rule > settings.default_rate_limit (from products table)
(user override always wins over group rule at any level)
8. Return allow/deny + effective rate limit + cost_units + permissions
```
### Caching Strategy
```
Cache key: `endpoint-acl:${userId}`
TTL: 5 minutes (same as existing ACL_CACHE_TTL)
Invalidation: On membership change, rule change, or override change
```
### Query examples
```sql
-- All groups for a user (including defaults and parents)
WITH RECURSIVE user_groups AS (
-- Direct memberships
SELECT r.resource_id AS group_slug
FROM resource_acl r
WHERE r.resource_type = 'acl-group-member'
AND r.user_id = $1
AND (r.meta->>'expires_at' IS NULL OR (r.meta->>'expires_at')::timestamptz > now())
UNION
-- Default groups
SELECT r.resource_id
FROM resource_acl r
WHERE r.resource_type = 'acl-group'
AND (r.meta->>'is_default')::boolean = true
),
-- Walk parent chain
group_tree AS (
SELECT g.group_slug, g2.meta->>'parent' AS parent_slug,
(g2.meta->>'priority')::int AS priority
FROM user_groups g
JOIN resource_acl g2 ON g2.resource_type = 'acl-group'
AND g2.resource_id = g.group_slug
UNION
SELECT gt.parent_slug, g2.meta->>'parent',
(g2.meta->>'priority')::int
FROM group_tree gt
JOIN resource_acl g2 ON g2.resource_type = 'acl-group'
AND g2.resource_id = gt.parent_slug
WHERE gt.parent_slug IS NOT NULL
)
SELECT DISTINCT group_slug, priority FROM group_tree ORDER BY priority DESC;
-- Effective rules for an endpoint + user's groups
SELECT r.*, g.meta->>'priority' AS group_priority
FROM resource_acl r
LEFT JOIN resource_acl g ON g.resource_type = 'acl-group'
AND g.resource_id = r.group_name
WHERE r.resource_type = 'endpoint-acl'
AND r.resource_id = $endpoint_key
AND (r.group_name = ANY($user_group_slugs) OR r.user_id = $user_id)
ORDER BY
CASE WHEN r.user_id IS NOT NULL THEN 1000 ELSE 0 END +
COALESCE((g.meta->>'priority')::int, 0) DESC;
```
---
## 6. API Endpoints (Admin)
All under `/api/admin/acl/` — admin-only.
### 6.1 Products
Products are defined in their own `products` table. They serve as the **grouping unit** for endpoints and define:
- **defaults**: `default_cost_units`, `default_rate_limit`, `default_rate_window` (stored in the JSONB settings column, inherited by all child endpoints)
- **enabled/disabled**: disabling a product sets `settings.enabled=false` denying all its endpoints
Product-level ACL rules (`product-acl` in `resource_acl`) let admins set blanket access per group without creating N rules per endpoint.
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/admin/products` | List all products from `products` table |
| `POST` | `/api/admin/products` | Create product in `products` table |
| `PUT` | `/api/admin/products/:slug` | Update product in `products` table |
| `DELETE` | `/api/admin/products/:slug` | Delete product from `products` table |
| `GET` | `/api/admin/acl/products/:slug/rules` | List product-level ACL rules (`product-acl` in `resource_acl`) |
| `POST` | `/api/admin/acl/products/:slug/rules` | Create product-level ACL rule (group or user) |
| `DELETE` | `/api/admin/acl/products/:slug/rules/:id` | Delete product-level rule |
### 6.2 Endpoints Registry
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/admin/acl/endpoints` | List all endpoints (filters: tag, product, is_public, is_admin) |
| `PUT` | `/api/admin/acl/endpoints/:id` | Update endpoint (cost_units, product, cancellable) |
| `POST` | `/api/admin/acl/endpoints/sync` | Force re-sync from OpenAPI spec |
### 6.3 Groups CRUD
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/admin/acl/groups` | List all groups (with membership counts) |
| `POST` | `/api/admin/acl/groups` | Create group |
| `PUT` | `/api/admin/acl/groups/:slug` | Update group |
| `DELETE` | `/api/admin/acl/groups/:slug` | Delete group (cascade rules) |
### 6.4 Group Members
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/admin/acl/groups/:slug/members` | List members |
| `POST` | `/api/admin/acl/groups/:slug/members` | Add member(s) |
| `DELETE` | `/api/admin/acl/groups/:slug/members/:userId` | Remove member |
### 6.5 Rules
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/admin/acl/rules` | List all rules (filter by group, endpoint, product, tag) |
| `POST` | `/api/admin/acl/rules` | Create/update rule |
| `DELETE` | `/api/admin/acl/rules/:id` | Delete rule |
| `POST` | `/api/admin/acl/rules/batch` | Bulk create/update rules |
### 6.6 User Overrides
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/admin/acl/overrides/:userId` | Get overrides for user |
| `POST` | `/api/admin/acl/overrides` | Create override |
| `DELETE` | `/api/admin/acl/overrides/:id` | Delete override |
### 6.7 Capability Query (Non-Admin)
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/acl/capabilities` | Returns the calling user's effective permissions across all endpoints |
Response shape:
```json
{
"groups": ["authenticated", "editor"],
"capabilities": {
"POST /api/pages": {
"allowed": true,
"permissions": ["create"],
"rateLimit": { "max": 100, "windowSec": 3600 }
},
"POST /api/posts": {
"allowed": true,
"permissions": ["create", "update"],
"rateLimit": null
},
"GET /api/competitors": {
"allowed": false,
"reason": "upgrade_required"
}
},
"tags": {
"Pages": { "create": true, "update": true, "delete": false },
"Posts": { "create": true, "update": true, "delete": true },
"AI": { "generate_image": false }
}
}
```
The `tags` summary is the **key UI bridge** — frontend can check `capabilities.tags.Pages.create` to decide whether to show the "New Page" button.
---
## 7. Middleware Integration
### New middleware: `aclMiddleware`
Inserted **after** `optionalAuthMiddleware` and **before** route handlers:
```
app.use('/api/*', optionalAuthMiddleware)
app.use('/api/*', adminMiddleware)
app.use('/api/*', aclMiddleware) // ← NEW
```
```typescript
// Pseudocode for aclMiddleware
export async function aclMiddleware(c: Context, next: Next) {
const path = c.req.path;
const method = c.req.method;
// 1. Skip for public endpoints (already handled)
if (PublicEndpointRegistry.isPublic(path, method)) {
return await next();
}
// 2. Admin bypass
if (c.get('isAdmin')) {
return await next();
}
// 3. Resolve user
const userId = c.get('userId') || 'anonymous';
// 4. Check endpoint ACL
const result = await resolveEndpointAcl(userId, path, method);
if (result.denied) {
return c.json({
error: 'Forbidden',
reason: result.reason, // 'no_permission' | 'rate_limited' | 'upgrade_required'
upgrade: result.upgradeHint // optional: which group would unlock this
}, 403);
}
// 5. Apply dynamic rate limit
if (result.rateLimit) {
const limited = await checkDynamicRateLimit(userId, path, method, result.rateLimit);
if (limited) {
return c.json({
error: 'Rate limit exceeded',
limit: result.rateLimit.max,
windowSec: result.rateLimit.windowSec,
retryAfter: limited.retryAfterSec
}, 429);
}
}
// 6. Attach capabilities to context (for downstream handlers)
c.set('aclPermissions', result.permissions);
c.set('aclGroups', result.groups);
await next();
}
```
---
## 8. Relationship to Existing Systems
### Coexistence with existing `resource_acl` usage
| `resource_type` | Controls | Example |
|-----------------|----------|---------|
| `vfs` | VFS folder access per user/group | Team can read `/shared` |
| `category` | Category visibility | Anonymous can view category X |
| `product` | **NEW:** Product definitions | Product `places` with prefix `/api/places` |
| `endpoint` | **NEW:** Endpoint registry | `GET:/api/competitors` with cost_units=1.0 |
| `acl-group` | **NEW:** Permission group defs | `editor` group with priority 20 |
| `acl-group-member` | **NEW:** User ↔ group mapping | Alice is in group `pro` |
| `endpoint-acl` | **NEW:** Endpoint permissions | Group `free` gets 10 searches/day |
All share the same table, same indexes, same `IAclBackend` pattern. The `resource_type` discriminator keeps them cleanly separated.
### Subsumes `PRODUCT_ACTIONS` (config/products.ts)
The hardcoded `PRODUCT_ACTIONS` map is **replaced** by `resource_type='product'` + `resource_type='endpoint'` rows:
| Old (`products.ts`) | New (`resource_acl`) |
|---------------------|----------------------|
| `endpoint: '/api/competitors'` | `resource_id = 'GET:/api/competitors'`, `path = '/api/competitors'` |
| `method: 'GET'` | Encoded in `resource_id` prefix |
| `costUnits: 1.0` | `meta.cost_units = 1.0` |
| `cancellable: true` | `meta.cancellable = true` |
| product key `'competitors'` | `meta.product = 'places'` → links to product row |
**Migration path:**
1. Add 2 new indexes to `resource_acl`
2. Seed `resource_type='product'` rows from `products.json` topology
3. First boot sync populates `resource_type='endpoint'` rows and auto-assigns products
4. Admin reviews and sets `meta.cost_units` for metered endpoints
5. `FunctionRegistry.findByRoute()` switches from reading `PRODUCT_ACTIONS` to querying `resource_acl` (cached)
6. `products.ts` config becomes dead code and is removed
### Migration of `PublicEndpointRegistry` / `AdminEndpointRegistry`
These become **seed data** for `resource_type='endpoint'` rows. The registries continue to function at the code level (for boot-time routing), and `syncEndpointsFromOpenAPI()` mirrors them into the database.
---
## 9. Implementation Phases
### Phase 1: Schema + Sync (Foundation)
- [ ] Add 2 new indexes to `resource_acl`
- [ ] Seed `resource_type='product'` rows from `products.json` topology
- [ ] Seed default `resource_type='acl-group'` rows (anonymous, authenticated, editor, admin)
- [ ] Write `syncEndpointsFromOpenAPI()` function (auto-assigns products by prefix)
- [ ] Run on boot after `registerProductRoutes()`
- [ ] Register new `IAclBackend` implementations: `ProductBackend`, `EndpointBackend`, `GroupBackend`
- [ ] Admin API: `GET /api/admin/acl/endpoints`, `GET /api/admin/acl/products`
- [ ] Admin API: `PUT /api/admin/acl/endpoints/:id` (set cost_units, product, cancellable)
- [ ] Migrate `FunctionRegistry.findByRoute()` to read from `resource_acl` cache
### Phase 2: Groups + Rules CRUD
- [ ] Admin API for groups, members, rules, overrides
- [ ] Zod schemas for all admin endpoints
- [ ] Group membership with expiry support
### Phase 3: Middleware Enforcement
- [ ] `aclMiddleware` with permission resolution
- [ ] Dynamic rate limiting integration
- [ ] Cache layer with invalidation hooks
### Phase 4: Capabilities API
- [ ] `GET /api/acl/capabilities` for frontend consumption
- [ ] Tag-level permission summaries
- [ ] Frontend integration (capability checks for UI gating)
---
## 10. Example Scenarios
### Scenario A: Product-level rate limiting (the common case)
Admin sets up the `places` product with tiered access — one `product-acl` rule per group covers ALL endpoints under `/api/places/*`:
```
# Groups
resource_type=acl-group resource_id=free meta={priority:10, is_default:true}
resource_type=acl-group resource_id=pro meta={priority:20, parent:"free"}
# Product definition in 'products' table via SQL or admin UI:
# INSERT INTO products (name, slug, settings) VALUES ('places', 'places', '{"enabled":true, "default_cost_units": 1.0}')
# Product-level rules (blanket for the whole product)
resource_type=product-acl resource_id=places group_name=free
meta={effect:"allow", rate_limit:10, rate_window:86400} ← 10/day
resource_type=product-acl resource_id=places group_name=pro
meta={effect:"allow", rate_limit:1000, rate_window:86400} ← 1000/day
```
Free user hits `GET /api/places/search` or `GET /api/places/details/:id` → both rate limited at 10/day total.
Pro user → 1000/day total across all places endpoints.
### Scenario B: Endpoint-level override within a product
The `find-email` endpoint under `places` is expensive. Admin adds an endpoint-level rule that overrides the product blanket:
```
# This overrides the product-acl for free users, specifically for find-email
resource_type=endpoint-acl resource_id=GET:/api/places/email/:id group_name=free
meta={effect:"allow", rate_limit:3, rate_window:86400} ← only 3/day for free
# Pro users: no endpoint override → inherits product-acl (1000/day)
```
Free user: 10/day for all places endpoints EXCEPT find-email which is 3/day.
Pro user: 1000/day for everything including find-email.
### Scenario C: Content creation permissions
```
resource_type=endpoint-acl resource_id=POST:/api/pages group_name=editor
permissions=[create] meta={effect:"allow"}
resource_type=endpoint-acl resource_id=PUT:/api/pages/:id group_name=editor
permissions=[update] meta={effect:"allow"}
resource_type=endpoint-acl resource_id=DELETE:/api/pages/:id group_name=editor
permissions=[] meta={effect:"deny"}
```
Frontend calls `GET /api/acl/capabilities`:
```json
{
"tags": {
"Pages": { "create": true, "update": true, "delete": false },
"Posts": { "create": true, "update": true, "delete": false }
}
}
```
→ UI hides delete buttons, shows create/edit buttons.
### Scenario D: User-specific override at product level
```
User 'alice' is in group 'free' (10 places calls/day via product-acl)
resource_type=product-acl resource_id=places user_id=<alice-uuid>
meta={effect:"allow", rate_limit:500, rate_window:86400, reason:"VIP customer"}
```
→ Alice gets 500/day on ALL places endpoints despite being in the free group.
(User overrides always win over group rules.)
---
## 11. File Structure
```
server/src/
├── middleware/
│ └── acl.ts # aclMiddleware (Phase 3)
├── products/serving/db/
│ ├── db-acl.ts # existing resource ACL orchestrator (extended with new backends)
│ ├── db-acl-db.ts # existing DB backend (unchanged)
│ ├── db-acl-vfs.ts # existing VFS backend (unchanged)
│ ├── db-endpoint-acl.ts # NEW: endpoint ACL resolution engine
│ ├── db-endpoint-acl-sync.ts # NEW: OpenAPI → resource_acl sync
│ ├── db-endpoint-acl-admin.ts # NEW: admin CRUD handlers
│ └── db-endpoint-acl-routes.ts # NEW: route definitions
```