Skip to content

Strapi backend

Strapi is the headless CMS and REST API backend for Sencai. It manages all platform data: users, organizations, instances, registrations, and more.

  • Version: v5
  • Port: 1337 (dev), routed via Traefik
  • Admin: http://localhost:1337/admin
  • Database: MySQL (backend-db:3306)
  • Tech: Node.js 20+, TypeScript, Knex (migrations)

Disclaimer: Strapi currently defines 212 content-types (as of Phase 3, see src/api/ in sencai.space). The list below is a small illustrative subset covering the main domains — it is not exhaustive. Always check src/api/ for the authoritative, current list before relying on a specific content-type name or shape.

  • User — Synced with Keycloak, profile fields (name, surname, email, jobTitle, address)
  • Registration — Signup requests, pending email verification
  • Organisation — Workspaces for teams
  • Organisation-Member (pivot) — Links users to orgs with RBAC roles (owner, admin, member, viewer)
  • CloudInstance — Provisioned VMs/containers on cloud providers
  • CloudProvider — Cloud provider credentials and configuration
  • CustomRegistry — Docker registries (private or public)
  • DockerHubCache — Popular Docker images cache
  • GitTemplate — Pre-built Git templates for rapid setup
  • Invitation — Organization invitations with token-based acceptance
  • ApiToken — User API tokens for programmatic access
  • Billing, Billing-Event — Usage/subscription billing records and event log
  • Billing-Portal — Customer-facing billing portal state
  • Organisation-Billing — Per-organisation billing configuration/tier
  • Agent-Config, Agent-Policy, Agent-Release — On-host fleet agent (sencai-agent) configuration, policy, and release tracking
  • Agent-Metric-Snapshot — Fleet telemetry snapshots
  • Fleet-Cohort — Grouping of fleet agents for rollout/policy targeting
  • K8s-Agent — Kubernetes fleet agent (k8s-agent) registration/state
  • Scim, Scim-Token — SCIM provisioning records and bearer tokens
  • Llm-Token-Usage, Llm-Usage — LLM facade (llm-service) usage/token metering records
  • Onboarding-State — Onboarding wizard progress per organisation/user
  • Feedback, Nps-Response — In-app feedback and NPS survey responses

Each content-type has:

  • REST endpoints: GET /api/<plural>, POST /api/<plural>, PUT /api/<plural>/:id, DELETE /api/<plural>/:id
  • Filtering & pagination: ?filters[field][$eq]=value&populate=*&sort=-updatedAt&pagination[pageSize]=10
  • Policies: RBAC via src/policies/ (e.g., is-organisation-member) — see Policies (RBAC) below for the fuller picture
  • Webhooks: Triggered on create/update/delete for event streaming

Strapi fires hooks at key lifecycle stages. Sencai uses these for cross-service sync:

File: src/lifecycles/user-kc-sync.ts

When a Strapi User is created or updated:

plugin::users-permissions.user afterCreate/afterUpdate
runWithKcSync() checks AsyncLocalStorage flag
→ If not suppressed: POST webhook-publisher/webhook-publisher
→ Event: { eventName: 'user.update', entity: user, ... }
→ RabbitMQ user.events exchange
→ auth-service-consumer processes
KC admin API updates user attributes

Loop Guard: Uses AsyncLocalStorage to prevent infinite loops:

  • runWithoutKcSync() — Disables KC sync for a specific async context
  • Used when KC → Strapi sync to avoid reflecting back to KC

When Strapi User updates, these fields sync to Keycloak:

  • email ↔ KC email
  • username ↔ KC sub (UUID, read-only)
  • confirmed/emailVerified ↔ KC emailVerified
  • blocked/enabled ↔ KC enabled
  • Profile: name, surname, jobTitle, street, city, zip_code, country

Strapi validates JWTs from Keycloak using jwks-rsa:

File: src/middlewares/keycloak-jwt.ts

// Validates JWT signature via Keycloak's JWKS endpoint
// Decodes to get user info (sub, email, name, etc.)
// If user doesn't exist in Strapi or profile incomplete:
// → Sync from KC before allowing access
// → Call runWithoutKcSync to prevent loop

JWT Content: Keycloak issues RS256-signed tokens with:

  • sub — User UUID (becomes Strapi username)
  • email, name, family_name — Profile fields
  • exp — 12-hour expiry
  • iat, iss, aud — Standard claims
Terminal window
curl -H "Authorization: Bearer <JWT>" http://localhost:1337/api/users/me
Terminal window
curl -X POST http://localhost:1337/api/organisations \
-H "Authorization: Bearer <JWT>" \
-H "Content-Type: application/json" \
-d '{"name":"Acme Corp","description":"AI team"}'
Terminal window
curl http://localhost:1337/api/cloud-instances?populate=* \
-H "Authorization: Bearer <JWT>"
Terminal window
curl "http://localhost:1337/api/organisations?pagination[page]=2&pagination[pageSize]=10&sort=-createdAt"

Strapi can send webhooks to external services. Sencai uses Webhook Publisher as the receiver:

  1. Go to Strapi Admin → Settings → Webhooks

  2. Create a new webhook:

    • URL: http://webhook-publisher:1347/webhook-publisher
    • Events: Check Entry Create, Entry Update, Entry Delete for target content-types
    • Headers: Optional auth headers
  3. Save

{
"event": "entry.create",
"createdAt": "2024-01-01T12:00:00Z",
"model": {
"uid": "plugin::users-permissions.user"
},
"entry": {
"id": 1,
"email": "user@example.com",
"username": "user",
"name": "John",
...
}
}

Sencai enforces policies via Strapi’s policy system. src/policies/ contains a set of reusable, global policies — is-organisation-member (shown below) is only one of them, not the sole mechanism. The full org/tenant-scoping set includes:

  • is-organisation-member — Basic membership check (any role) for a given organisation
  • is-organisation-role — Membership check with a minimum role in the hierarchy (owner (4) > admin (3) > member (2) > viewer (1))
  • is-organisation-scoped — Parametrized, reusable isolation policy (config.minRole, default viewer) with a platform-admin bypass; resolves the organisation for record-id routes (:id/:documentId) from the actual record’s organisation relation, not from client-supplied query filters
  • is-organisation-scoped-or-cross-tenant — Variant that also allows access via an approved cross-tenant organisation-relationship (MSP/agency model)
  • is-org-auditor / is-org-or-admin-auditor — Read-scoped policies for audit-log/auditor-role access within an organisation (or platform admin)

File: src/policies/is-organisation-member.ts

module.exports = async (policyContext, config, { strapi }) => {
const { auth } = policyContext.state;
const { organisationId } = policyContext.params;
// Check if user is member of org
const member = await strapi.entityService.findMany(
'api::organisation-member.organisation-member',
{ filters: { user: auth.user.id, organisation: organisationId } }
);
if (!member) throw new Error('Not a member of this organisation');
};

Apply to routes:

src/api/cloud-instance/routes/cloud-instance.js
{
method: 'GET',
path: '/cloud-instances/:id',
handler: 'cloud-instance.findOne',
config: {
policies: ['is-organisation-member']
}
}

Org-scoping is not a solved problem — treat it as defense-in-depth

Section titled “Org-scoping is not a solved problem — treat it as defense-in-depth”

is-organisation-scoped used to have an IDOR-class bypass: an attacker could resolve the organisation from an attacker-controlled query.filters[organisation] value while operating on a record identified by a different, victim-owned :id/:documentId in the path. This was one of the findings remediated during the Phase 3 security work (F3.SECREM, tracked as S0-1 in SECURITY-AUDIT.md).

The fixed policy now resolves the organisation for record-id routes exclusively from the target record’s own organisation relation (never from client-supplied query filters) and fails closed (denies access) whenever the organisation cannot be resolved. However:

  • For collection routes (find/create without a path :id), the policy is not the authoritative source of truth for which rows are returned — the controller itself must still scope the query server-side (fetch the caller’s organisation IDs and apply an explicit where filter).
  • Some content-type controllers implement their own authoritative isolation on top of (or instead of) the shared policy; in those cases is-organisation-scoped is only a second layer of defense, not the sole guard.

Do not assume org-scoping is fully and generically solved by attaching a policy to a route — verify the specific controller’s query-scoping behavior, especially for collection endpoints.

Strapi v5 uses Knex for migrations.

Files: database/migrations/

database/migrations/[timestamp]_add_new_field.js
cd sencai.space
npm run knex -- migrate:make add_new_field
exports.up = async (knex) => {
await knex.schema.createTable('users_permissions_users', (table) => {
table.increments('id');
table.string('email').notNullable().unique();
table.timestamps();
});
};
exports.down = async (knex) => {
await knex.schema.dropTable('users_permissions_users');
};

Important: Never modify or delete existing migrations. Always create new ones.

Migrations run automatically on service startup. To run manually:

Terminal window
npm run knex -- migrate:latest

File: src/api/organisation/controllers/organisation.ts

import { factories } from '@strapi/strapi';
export default factories.createCoreController(
'api::organisation.organisation',
({ strapi }) => ({
// Override or extend default actions
async findOne(ctx) {
// Custom logic here
const { id } = ctx.params;
const entity = await strapi.entityService.findOne('api::organisation.organisation', id);
return entity;
}
})
);

File: src/api/organisation/services/organisation.ts

import { factories } from '@strapi/strapi';
export default factories.createCoreService(
'api::organisation.organisation',
({ strapi }) => ({
// Reusable business logic
async createWithDefaults(data) {
return strapi.entityService.create('api::organisation.organisation', {
data: { ...data, status: 'active' }
});
}
})
);

Sensitive fields (passwords, tokens) are encrypted with ADMIN_ENCRYPTION_KEY:

// In content-type schema
{
type: 'password',
encrypted: true // Uses ADMIN_ENCRYPTION_KEY for storage
}
Terminal window
curl http://localhost:1337/api/health
  1. http://localhost:1337/admin
  2. Create admin user on first login
  3. Define content-types, manage data, configure webhooks
Terminal window
docker logs -f backend
# Or if running locally:
npm run develop
Terminal window
# MySQL (Strapi database)
mysql -h 127.0.0.1 -u strapi -p sencai_db
# List users
SELECT id, email, username, confirmed FROM up_users;
# Check organisations
SELECT id, name, description FROM organisations;

Q: “Model webhook not found” error

A: Strapi v5 removed the webhook model. Webhooks must be created via admin UI (Settings → Webhooks), not via code.

Q: Migrations not running

A: Ensure database is accessible. Check MySQL container logs: docker logs backend-db

Q: CORS errors on frontend requests

A: Strapi CORS is configured in config/middlewares.ts. Add frontend URL to allowedOrigins.

Q: JWT validation fails

A: Verify Keycloak URL in .env: KEYCLOAK_URL=http://keycloak:8080/kc (internal, with /kc prefix).

Q: User not syncing to Keycloak

A: Check webhook-publisher is running and RabbitMQ is accessible. See RabbitMQ logs for event processing errors.