Last Updated: 2026-05-06 Status: Active
Authoritative map of every permission check in blog-service: what string controls what route, who can hold it, and what bypass logic applies. Source of truth: apps/blog-service/src/index.ts + apps/blog-service/src/middleware/auth.middleware.ts.
1. The Five Scopes
Blog-service uses five permission strings. They map 1:1 to user-facing capabilities.
| Permission | Grants |
|---|---|
blog:posts.read |
List + read posts (any status), categories, tags |
blog:posts.create |
Create posts, categories, tags |
blog:posts.update |
Update posts, categories, tags (including slug changes) |
blog:posts.delete |
Delete posts, categories, tags |
blog:posts.publish |
Publish, unpublish, schedule, unschedule posts |
IMPORTANT
Categories and tags reuse the post scopes — they do not have their own blog:categories.* permissions. Granting blog:posts.create lets a user create both posts AND categories. This is intentional to keep the permission surface small.
2. The Bypass — system:owner
Anyone with system:owner in their permissions array passes every check unconditionally.
// apps/blog-service/src/middleware/auth.middleware.ts:13
const hasPermission =
permissions.includes(permission) || permissions.includes("system:owner")Workspace owners get this from manager-service automatically. There is no second tier of admin — system:owner is the only universal grant.
3. Permission → Route Map
Every line below mirrors apps/blog-service/src/index.ts:154-174.
Posts (/admin/posts)
| Method | Path | Required Permission |
|---|---|---|
GET |
/admin/posts |
blog:posts.read |
GET |
/admin/posts/:id |
blog:posts.read |
POST |
/admin/posts |
blog:posts.create |
PUT |
/admin/posts/:id |
blog:posts.update |
DELETE |
/admin/posts/:id |
blog:posts.delete |
POST |
/admin/posts/:id/publish |
blog:posts.publish |
POST |
/admin/posts/:id/unpublish |
blog:posts.publish |
POST |
/admin/posts/:id/schedule |
blog:posts.publish |
POST |
/admin/posts/:id/unschedule |
blog:posts.publish |
Categories (/admin/categories)
| Method | Path | Required Permission |
|---|---|---|
GET |
/admin/categories |
blog:posts.read |
POST |
/admin/categories |
blog:posts.create |
PUT |
/admin/categories/:id |
blog:posts.update |
DELETE |
/admin/categories/:id |
blog:posts.delete |
Tags (/admin/tags)
| Method | Path | Required Permission |
|---|---|---|
GET |
/admin/tags |
blog:posts.read |
POST |
/admin/tags |
blog:posts.create |
PUT |
/admin/tags/:id |
blog:posts.update |
DELETE |
/admin/tags/:id |
blog:posts.delete |
Public routes — no permission required
/public/posts/*, /public/categories, /public/tags are gated only by API key validation at the gateway. They never enter requirePermission.
4. How a Permission Reaches blog-service
The JWT is the source of truth. Blog-service never re-validates the JWT — it trusts the gateway and reads the already-decoded permissions from the x-user-permissions header.
5. Failure Modes
| Symptom | Cause | Where to look |
|---|---|---|
403 { error: "Forbidden", message: "Missing required permission: blog:posts.update" } |
User's JWT didn't include the listed permission | manager-service role/permission assignment for this user |
| Owner suddenly can't do anything | system:owner missing from JWT |
manager-service tenant-owner role config |
All /admin/* routes return 403 with "Direct access forbidden" |
This is the x-gateway-key check, not a permission. Different middleware. |
deployment.md §6 — GATEWAY_SECRET mismatch |
| 200 from list, 403 from create with same JWT | Read but not create granted — common for editor-only roles | Expected. Ask manager-service to grant blog:posts.create |
6. Recommended Role Bundles
These aren't hard-coded in blog-service — manager-service decides what permissions go in a JWT. But these are the bundles that make sense for blog work:
| Role | Permissions | Use case |
|---|---|---|
| Owner | system:owner |
Workspace owner. Full access everywhere. |
| Editor | blog:posts.read, .create, .update, .publish |
Writes and publishes; can't delete |
| Author | blog:posts.read, .create, .update |
Drafts only — needs an editor to publish |
| Viewer | blog:posts.read |
Read admin views without making changes |
| Public consumer | (none — uses API key) | External SDK reading published posts |
7. Adding a New Permission
If you add a new admin route and want a tighter scope than blog:posts.*:
-
Pick a name following the pattern
blog:<resource>.<verb>(e.g.blog:authors.create). -
Wire it in
apps/blog-service/src/index.tsabove the route registration:app.on("POST", "/admin/authors", requirePermission("blog:authors.create")) -
Update manager-service to include the new string in the JWT for whichever role should grant it. Without this step the JWT never carries it and even owners hit 403 (well — owners pass via
system:owner, but everyone else is locked out). -
Add the row to this doc in §3.
-
Add a test that asserts a JWT without the permission gets 403 and a JWT with
system:ownerpasses.
NOTE
Never gate behavior on the absence of system:owner. The bypass is universal by design — if you want a check the owner can't bypass, encode it as a tenant-level setting, not a permission.
8. Related
- Where the
requirePermissionmiddleware lives: apps/blog-service/src/middleware/auth.middleware.ts - Where routes are wired: apps/blog-service/src/index.ts lines 154–174
- How the JWT is verified at the gateway: architecture.md §4
- What happens when permission check passes but tenant is missing: glossary.md §1 —
requireTenant