logicspike/docs

Blog Engine

Blog System — Permissions Reference

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 §6GATEWAY_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

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.*:

  1. Pick a name following the pattern blog:<resource>.<verb> (e.g. blog:authors.create).

  2. Wire it in apps/blog-service/src/index.ts above the route registration:

    app.on("POST", "/admin/authors", requirePermission("blog:authors.create"))
  3. 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).

  4. Add the row to this doc in §3.

  5. Add a test that asserts a JWT without the permission gets 403 and a JWT with system:owner passes.

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.


Blog Engine