Kinotic OS

System Security

Platform-level security architecture in Kinotic OS — scope isolation, credential separation, OIDC model.

Overview

This page describes the architectural choices behind Kinotic's IAM. It is the why; for the operational how (signup flows, login flows, endpoint reference) see Organization Management, and for the layered enforcement applied to every request path see Defense in Depth.

The platform supports email/password and OIDC authentication, isolates user pools by scope, stores credentials separately from user records, and uses standalone OidcConfiguration entities that are referenced (not embedded) by the scopes that consume them.

Three Scope Layers

Every ParticipantIdentity carries its scope structurally, in two optional string fields — organizationId and applicationId. Which of them is set determines the layer: neither set → SYSTEM, organizationId only → ORGANIZATION, both set → APPLICATION.

LayerScope fieldsWhoAuth Path
OrganizationorganizationId set, applicationId nullCustomer org admins and membersEmail/password or OIDC (platform-social or per-org SSO) — see Organization Management
ApplicationorganizationId and applicationId both setEnd-users of an application owned by an orgEmail/password or OIDC — credentials and OIDC configs are scoped to the application
Systemboth nullPlatform operators running kinotic-server itselfNo login path today; planned to move to Microsoft Entra

A user with email jane@example.com in Organization A is a fundamentally different identity from jane@example.com in Application B. They have separate ParticipantIdentity rows, separate IdentityCredential rows, and separate authentication paths. This prevents accidental cross-scope access and lets the same person have different authentication methods at different scopes (e.g. password at the org, federated SSO at one of the org's apps).

How users are differentiated

A user's identity is determined by the combination of three values: email + organizationId + applicationId. The same email address can exist many times across the platform — each combination produces a completely separate user record with its own credentials, enabled state, and OIDC links.

For example, the same person could carry four distinct identities:

  • A developer in their organization (org-A-id, no application)
  • A test account in one of their applications (org-A-id + app-A-id)
  • A customer end-user in a different application (org-A-id + app-B-id)
  • A second org as the same person via social-OIDC (org-B-id, no application)

Each is a separate row. Disabling one does not disable the others. Authentication against one does not grant access to any of the others. Scope isolation is enforced at every lookup — there is no query that returns users across scopes.

For OIDC users, the platform additionally tracks (oidcSubject, oidcConfigId) so that one social identity can map to multiple ParticipantIdentity rows in different orgs. Social login resolves that pair to a single org user and takes the first match, so a person who joined two organizations with the same social identity lands in one of them without choosing.

Why three layers

Two layers (platform vs tenant) would force organizations and their applications to share a user pool. That doesn't work because:

  • An organization's developers should not automatically be end-users of every application they manage.
  • Application end-users should not have access to organization-level tooling.
  • Different applications under the same organization may serve completely different user populations.

Three layers map directly to real-world trust boundaries: the platform operator trusts the infrastructure, the organization trusts its developers, and the application trusts its users.

Authentication Methods

Both methods are available at the Organization and Application layers:

  • Email/Password — Credentials are verified against bcrypt hashes. Password hashes are stored in a separate IdentityCredential entity (a different Elasticsearch index) and are architecturally invisible to the rest of the system. User CRUD operations have no way to accidentally expose credentials.
  • OIDC — JWT tokens are validated against OidcConfiguration entities referenced by the target scope. The platform verifies signature, issuer, audience, and expiration via JWKS.

A browser authenticates over HTTP and carries a session cookie; the STOMP WebSocket upgrade reads the logged-in Participant back out of that session, so the browser never handles a token. Scope is structural rather than header-supplied: an organization login establishes an OrganizationParticipant, an application login an ApplicationParticipant, and that participant type is what scopes the caller's authority for the rest of the connection. A non-browser client presents a Kinotic JWT instead, whose organizationId / applicationId claims carry the same scope. The session cookie is named __Host-kinotic-session: browsers accept a __Host- cookie only when it is Secure, scoped to path / and set without a Domain, so a page on a sibling host of the API, such as a published UI under apps.kinotic.ai, can neither plant nor override it. The cookie is SameSite=Lax, which sends it from the API's own site, the one apps.kinotic.ai shares in production; kinotic.apiGateway.sessionCookieSameSite: NONE sends it from any origin the CORS pattern admits, for an environment whose published sites and API live on unrelated domains.

OIDC Configuration

Provider configurations come in two types that share a shape but not a scope. Both extend BaseOidcConfigurationname, provider, clientId, secretNameRef, authority, audience, enabled — and each lives in its own Elasticsearch index.

TypeIndexScopeLifecycle
OrgSignupOidcConfigurationkinotic_org_signup_oidc_configurationPlatform-wide, unscopedSeeded by SQL migration; not editable through the org admin UI
OidcConfigurationkinotic_oidc_configurationOwned by one organization (organizationId enforced by AbstractCrudService)Managed by the owning org

The Kinotic-curated social providers (GitHub, Google, Microsoft) are the first type — they power the "Continue with X" buttons on org signup and org login, and they belong to no organization because signup happens before one exists. Everything an organization configures for itself is the second type.

Why two types rather than one with a flag

The difference between the two is scope, and scope is exactly what must never be got wrong. OidcConfiguration is OrganizationScoped and reached through an org-scoped repository that requires an organizationId on every lookup; the social configs are deliberately unscoped. Separate types mean an org-scoped query cannot return a platform config, and a platform lookup cannot reach another org's SSO — the boundary is enforced by the type system rather than by remembering to filter. A single entity with an isPlatform boolean would put that guarantee back in the hands of every caller.

How an org's configurations are referenced

Association is by reference, not embedding, so the same config can serve more than one purpose without being copied:

  • Organization.ssoConfigId — the single OidcConfiguration the org uses for org-level SSO, or null when it has none.
  • Application.oidcConfigurationIds — the configs each application accepts for application-level login.

The same id may legitimately appear in both, when an org uses one Okta tenant for both admin SSO and a customer-facing app.

Resolving what a scope offers

OidcConfigurationService.findEnabledForScope(organizationId, applicationId) is the single answer to "which providers may this login page show", keyed on the same (organizationId, applicationId) pair the rest of the platform scopes by. An application scope returns the enabled configs that application references; an organization scope returns the enabled social providers plus the org's own SSO config. The login, invite-accept, and signup pages all read it, so what a page renders and what its start endpoint accepts cannot drift apart.

Client secrets are never stored on the configuration row. secretNameRef names a secret an operator placed in external storage, which SecretReferenceResolver fetches at flow time — Azure Key Vault in a deployed environment, KINOTIC_AKV_* environment variables in development.

Sign-Up

Self-service signup is wired up for the Organization scope. Both email/password and social-IdP entry points create the Organization together with the founding ParticipantIdentity — see Organization Management for the step-by-step.

For the Application scope, end-user provisioning is admin-driven: an org admin (or the application's own admin tooling) creates ParticipantIdentity rows in the application's scope, or invites them — see Organization Management. There is no self-service sign-up into an application scope; an application's OIDC login authenticates users who already exist in that scope.

Design Decisions

Credential separation

Authentication secrets — user password hashes and machine client-secret hashes — are stored in a separate IdentityCredential entity, keyed by identity id and not exposed through any published service interface. The identity entity is part of the public API — returned by CRUD operations, displayed in UIs, passed around in service calls. Storing credentials separately means secret hashes are architecturally invisible to the rest of the system.

Scope as string fields

The user entity stores organizationId and applicationId as plain string fields rather than typed references, and the layer is read structurally from which ids are set. This avoids coupling ParticipantIdentity to Organization/Application, keeps queries simple (term filters on keyword fields), and lets the scope model evolve without migrating user records.

Single identity entity for all scopes

All three scope layers share the same ParticipantIdentity hierarchy, distinguished by the scope fields. Separate types per scope would triple the service interfaces and implementations with no behavioral difference — the authentication logic is identical across scopes.

Users, delegates, and machines

A ParticipantIdentity is a person (UserParticipantIdentity), a client a person has authorized to act on their behalf (DelegatingParticipantIdentity) — the Kinotic CLI via the device-code grant, or an MCP host such as an LLM assistant via the PKCE authorization-code grant — or a non-human caller with its own credential (MachineParticipantIdentity), such as a platform daemon or an external client of an organization's application API. A delegate carries its owner's exact scope, is unique per (owner, client) — approving the same client twice reuses one delegate, each approval issuing a fresh refresh-token lineage under it — and authenticates only with Kinotic-issued tokens whose sub is the delegate's own id, so every request records which client acted, not just whose authority it used.

Revocation follows from the model: authentication re-reads the identity on every token validation, so disabling a delegate cuts that client off on its next request even if its access token has not expired, and a delegate authenticates only while its owner is enabled — disabling a user revokes every client authorized on their behalf with no cascade to miss. Deleting a user deletes their delegates outright.

Each approval issues a refresh-token lineage under the delegate — a session, labeled with the device name the client supplied (the CLI sends one with device_name on the device-authorization request). The published DelegateService gives the signed-in user their own delegates and sessions — list them, end a single session, or revoke a delegate entirely. Alongside it, ProfileService reads and writes the caller's own identity: findMyProfile returns it and updateDisplayName sets the name shown wherever they appear. Both services address the calling user rather than taking an id, and admit only a person — a delegate cannot read or edit the account that authorized it. The SPA surfaces them as the Profile and Connected apps pages under the account menu.

Machine identities

A machine connects the same way every Kinotic client does — over the STOMP gateway, authenticated by credentials on the WebSocket upgrade: its identity id as clientId and the secret issued at provisioning as clientSecret. A clientId containing @ is a user email; anything else resolves only to a MachineParticipantIdentity, so a user's id and password can never authenticate as a machine. The machine's scope comes structurally from its own identity — no scope headers needed. There is no token to manage: the client library owns connect and reconnect, re-presenting the same static credentials, so any crash or restart self-heals.

await Kinotic.connect({server: {host, port}, credentials: new BasicCredentialsResolver(machineId, machineSecret)})

Provisioning generates the secret — it is returned in plaintext exactly once and stored only as a hash, in the same credential store that holds user password hashes. The secret is verified on every handshake, so disabling the machine identity cuts it off on its next connection, and rotating the secret invalidates the old one immediately.

Machines today come in two shapes: SYSTEM-scope platform daemons such as the vm-manager, and APPLICATION-scope API clients of one application — where the machine acts with that application's scope, exactly the position an application end-user occupies.

Org members manage the machines of their applications through the published MachineService — create (disclosing the generated secret once), list, rotate the secret, disable/enable, and remove — surfaced in the SPA as the Machines page under each application. Every operation proves the application belongs to the caller's organization; machines of other organizations are invisible.

Multi-org identity keyed by (oidcSubject, oidcConfigId)

For OIDC users, identity across org-scoped ParticipantIdentity rows is keyed by the OIDC sub claim plus the configId — not email. Email is mutable at the IdP and the same email across two IdP tenants is two different people. The sub is stable within an issuer, and pairing it with the configId disambiguates issuers.

Auto-derived organization id

The Organization.id is derived from the user-supplied name (slugified) at signup time. This supports subdomain-based tenant identification (e.g. customer.kinotic.ai) without surfacing UUIDs in URLs.

Client identity is a domain, not a string

MCP hosts identify themselves with an OAuth Client ID Metadata Document: the client_id is an HTTPS URL, and the gateway fetches the JSON document it names on each authorization request (cached for kinotic.domain.oauth.clientMetadataCacheTtl). The document registers the client's redirect_uris and client_name.

The security property is that the client had to control that host to serve the document there, so the consent page displays the client_id host alongside the claimed name — a client cannot present itself as claude.ai without owning claude.ai. Under the dynamic registration this replaces, client_name was self-asserted by whoever called the registration endpoint.

Validation, per the draft: the URL must be https with a path component and no fragment, userinfo, or dot segments (§3); the host must not resolve to a special-use address (§6.5); the fetch must answer 200 with no redirects followed (§4) and stay under clientMetadataMaxBytes (§6.6); and the document must name its own URL as client_id and carry no client secret (§4.1). Failures abort the authorization request and are never cached (§4.3, §4.4).

kinotic.domain.oauth.allowedClientIds lists the document URLs that may start a flow, and is required — a deployment names the clients it accepts or fails to start. A client_id that is not listed is rejected, so clients are onboarded explicitly, the deployment pattern of §6.10. A host that validates against the rules above still needs its document URL added before it can connect.

Each Anthropic surface presents its own document URL, and kinotic-server ships both:

Clientclient_id
Claude Codehttps://claude.ai/oauth/claude-code-client-metadata
Claude web, desktop, and mobile custom connectorshttps://claude.ai/oauth/mcp-oauth-client-metadata

Bootstrap

kinotic-migration runs to completion before kinotic-server starts. Migrations are versioned SQL applied once per version: V1__init.sql creates the Elasticsearch indices, and V2__kinotic_data_inserts.sql seeds the curated social providers into kinotic_org_signup_oidc_configuration. A migration whose filename carries environment suffixes — V3__kinotic_test_users.development.test.sql — is applied only in those environments, which is how test fixtures stay out of production.

There is no startup bootstrap step for OIDC. A seeded provider row carries only a secretNameRef; the client secret itself is placed in external secret storage by the operator and read at flow time, so no secret passes through a migration or a config file.

Network Security

  • All client-to-server communication occurs over WebSocket with TLS in any non-local environment.
  • The api-gateway port (default 58503) carries STOMP/WebSocket on /v1 and the auth REST endpoints on /api/*.
  • TLS termination is handled at the ingress layer (KinD: mkcert + nginx; Azure: cert-manager + LoadBalancer).

What This Design Does Not Cover

  • Authorization — Roles and policies. The authenticated session currently carries an empty roles list; future work populates it from the policy system.
  • Groups — Group entities and membership management.
  • System-scope OIDC — Platform operators do not authenticate through any of the OIDC flows above; that's a separate, infrastructure-tier authentication path (deferred).
  • Account linking — Linking an existing local account to a social identity is on the roadmap but not built.
Copyright © 2026