663 lines
27 KiB
Markdown
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
|
|
```
|