Skip to content

Security and authorization

SeeAMess stores no passwords and has no user table. People sign in at an OpenID Connect provider; the editor uses the authorization code flow with PKCE, and the engine validates access tokens locally against the provider’s published keys, so no call to the provider is made per request.

  • Only RS256 and ES256 signatures are accepted. The engine checks the signature, iss, aud, exp and nbf, with 30 seconds of leeway.
  • Keys are cached for an hour. A token with an unknown kid re-fetches them at most once a minute, so key rotation needs no restart and forged kids cannot flood the provider.
  • Roles come from a claim (roles by default, dotted paths like realm_access.roles work).
  • For local development, dev mode accepts one static token (SEEAMESS_DEV_TOKEN) as an administrator.

The variables are in Configuration.

Every Authoring command and read, and every draft or preview query, calls one function:

authorize(subject, action, resource) → allow | deny(reason)
  • subject: the user, its roles and token claims.
  • action: content.read, content.edit, content.publish, content.delete, schema.apply, media.upload and so on. Extensions add their own.
  • resource: kind, ID, type, ancestors and locale.

A denial is 403 with the policy’s reason. Because there is exactly one call site per handler, the engine behind it can be replaced without touching handlers.

Engine (SEEAMESS_POLICY) Use
acl (default) Inherited access lists on the content tree: the same model and mask bits as Optimizely’s, so imported rights apply unchanged.
external Calls a policy service over HTTP (SEEAMESS_POLICY_URL): OPA, Cedar, OpenFGA or your own. Decisions are cached for 5 seconds; a timeout or error is a deny.
allow-all Development only. Refused outside dev mode.

With acl, each content may have rows of principal and mask. The bits are Optimizely’s: Read 1, Create 2, Edit 4, Delete 8, Publish 16, Administer 32. Content without rows inherits from its nearest ancestor that has some. With no rows anywhere up the tree, Everyone may read and only admins may do anything else. Members of SEEAMESS_ADMIN_ROLES pass every check.

The content view tells the editor what you may do (permissions: read, create, edit, delete, publish, administer), so it only offers allowed commands. The server still checks every command, and lists leave out content you may not read.

Published content is public by default. When Everyone may not read a content, the Projector writes the allowed principals into the document as audiences. Delivery then serves it only to a caller with a valid token whose principals match: 401 without a token, 403 with the wrong one. Responses that depend on the caller are Cache-Control: private, no-store, so a shared cache never serves them to someone else.

Public documents never cause token validation and cost no extra query.

POST /authoring/v1/content/{id}/preview-link returns a short-lived link (30 minutes) bound to one version, signed with HMAC-SHA256. An iframe or a colleague can open the draft without a session. The token also opens the files of media the version refers to, one level deep. Set SEEAMESS_PREVIEW_SECRET so links survive a restart.

  • Rich text is sanitized against an allow-list when written and again when projected, so content synced from another system is never served unsanitized. Scripts, styles, event handler attributes and style attributes are removed.
  • Uploads are typed by their bytes, never by the client’s claim. SVG is sanitized before it is stored and served with a sandboxing Content-Security-Policy.
  • Renditions are made only for allow-listed widths or signed parameters, so URLs cannot be varied to fill the cache or burn CPU.

Every successful command writes an audit record in its own transaction, so a command and its record commit or roll back together. Denied commands are recorded too. Extensions cannot disable it. Admins read it with GET /authoring/v1/audit or in the editor under Admin → Audit log.

Everything the core sends to an extension is signed with the extension’s secret over a timestamp. Calls go only to the registered base URL, redirects are never followed, answers are size- and time-limited, and outside dev mode the base URL may not resolve to a loopback, link-local or private address (checked at every connection, which defeats DNS rebinding).