FabricFabricHarness
Operating

Authentication and RBAC

Bind authenticated principals, tenants, permissions, and SSO identities to every server operation.

fh dev and generated Node builds ship a simple Bearer-token auth surface for solo deployments. Enterprise hosts use authenticate to return a principal with tenant and permission scopes. The server derives the execution actor from that principal, so clients cannot spoof audit identity in request bodies.

Local/dev mode remains open when no auth is configured. Production mode fails closed: only /health and /ready are anonymous, and every other HTTP or WebSocket route returns 401 unless the bearer token or custom resolver authorizes it.

Default: Bearer token

FABRIC_HARNESS_API_TOKEN=changeme fh dev --port 9111
curl -H 'Authorization: Bearer changeme' http://localhost:9111/sessions

WebSocket upgrades use the same token via ?token= query param (browsers can't set headers on WebSocket constructor):

new WebSocket('wss://app.example.com/sessions/abc/ws?token=changeme');

Custom resolver: extractAuthToken

When you have an existing identity layer (cookies, JWT, SSO terminator), pass a custom resolver:

import { startDevServer, parseCookie } from '@fabric-harness/node';

await startDevServer({
  extractAuthToken: (req) => {
    const cookie = parseCookie(req, 'session');
    if (!cookie) return undefined;             // fall through to bearer check
    return verifySessionCookie(cookie);        // your validator returns true/false
  },
});

The resolver returns:

  • true — authorize the request
  • false — reject with 401
  • undefined — fall through to the built-in bearer-token check

Same hook works for HTTP requests AND WebSocket upgrades.

Enterprise principals and permissions

Use authenticate when the server must enforce tenant isolation and route-level RBAC:

import { startDevServer } from '@fabric-harness/node';

await startDevServer({
  authenticate: async (req) => {
    const claims = await verifyCompanyJwt(req.headers.authorization);
    if (!claims) return false;
    return {
      id: claims.sub,
      kind: claims.type === 'user' ? 'user' : 'service-principal',
      provider: 'company-oidc',
      tenantId: claims.organizationId,
      displayName: claims.name,
      ucPrincipal: claims.databricksPrincipal,
      permissions: claims.permissions,
    };
  },
});

The built-in permission scopes are:

PermissionOperations
agent:invokeJobs, persistent prompts, dispatch, and channel ingress
session:readSession, run, timeline, metrics, and conversation reads
session:abortAbort an active persistent submission
session:deleteCascade-delete a settled persistent instance
approval:read, approval:writeInspect and vote on approvals
artifact:readRead artifacts and attachments
build:readRead build manifests
admin:readAdmin and OpenAPI routes

* grants every permission and session:* grants all permissions for one resource. Omitting permissions keeps compatibility with existing custom authenticators and grants unrestricted access; enterprise authenticators should always return an explicit array.

tenantId binds the principal to one tenant. A request for a different X-Fabric-Tenant or ?tenant= receives 403. Use tenantIds instead when an operator may explicitly select from a known set. A single-value tenantIds allowlist binds implicitly when the request omits a tenant; an allowlist containing multiple tenants requires an explicit permitted tenant selection and otherwise returns 403. Omitting the selection never widens access to every tenant. The resulting principal and tenant propagate through submissions, entries, approvals, tool attribution, and telemetry.

Add application-specific checks after the built-in scopes with authorize:

await startDevServer({
  authenticate: companyAuthenticator,
  authorize: ({ principal, permission, tenantId }) =>
    permission !== 'session:delete' ||
    (principal.roles?.includes('tenant-admin') === true && tenantId !== undefined),
});

HTTP and WebSocket upgrades use the same authentication, tenant, and authorization pipeline.

OIDC and JWKS validation

Install jose, then use the built-in validator for any OpenID Connect provider. It verifies the signature against a local or remote JWKS, issuer, audience, required claims, algorithms, expiry, and clock tolerance. Remote JWKS keys are cached and refreshed when providers rotate signing keys.

pnpm add jose
import { oidcJwtAuthenticator, startDevServer } from '@fabric-harness/node';

await startDevServer({
  authenticate: oidcJwtAuthenticator({
    issuer: 'https://identity.example.com',
    audience: 'fabric-api',
    jwksUri: 'https://identity.example.com/.well-known/jwks.json',
    provider: 'company-oidc',
    claims: {
      tenantId: 'organization_id',
      roles: 'roles',
      groups: 'groups',
      permissions: 'permissions',
    },
    groupRoles: {
      'platform-operators': ['operator'],
    },
    rolePermissions: {
      operator: ['session:*', 'approval:*', 'artifact:read'],
    },
  }),
});

The validator returns undefined when no supported token is present, so a configured legacy API token can remain as fallback. An invalid or expired JWT returns false and the server responds with 401. Use mapPrincipal when provider claims need logic beyond declarative claim mapping.

Microsoft Entra ID

entraIdAuthenticator() configures the tenant-specific v2 issuer and rotating JWKS. It maps oid, tid, name, roles, and groups, and treats idtyp: app as a service principal.

authenticate: entraIdAuthenticator({
  tenantId: process.env.ENTRA_TENANT_ID!,
  clientId: process.env.ENTRA_CLIENT_ID!,
  groupRoles: { [process.env.ENTRA_OPERATOR_GROUP!]: ['operator'] },
  rolePermissions: { operator: ['session:*', 'approval:*', 'admin:read'] },
})

Databricks Apps

databricksAppsOidcAuthenticator() validates workspace-issued JWTs and accepts either an Authorization: Bearer token or the Databricks Apps x-forwarded-access-token header. It maps the workspace, email/Unity Catalog principal, roles, and groups into the same server identity.

authenticate: databricksAppsOidcAuthenticator({
  workspaceHost: process.env.DATABRICKS_HOST!,
  audience: process.env.DATABRICKS_APP_CLIENT_ID!,
  rolePermissions: { 'data-steward': ['session:read', 'approval:*'] },
})

If the workspace uses a custom issuer, account-level identity federation, or a proxy JWKS, set issuer and jwksUri explicitly. Keep the service-principal path for unattended traffic and use the forwarded user token only when operations should inherit that user's Databricks grants.

Delete a persistent instance

Deletion is deliberately restricted to settled work:

curl -X DELETE \
  -H "Authorization: Bearer $TOKEN" \
  https://agents.example.com/agents/support/customer-42

The operation removes every named session for that agent instance, its settled submissions, conversation streams, session artifacts, and content-addressed attachments. If work is still active, the server requests an abort and returns 409; retry after settlement. Durable custom stores must implement SessionStore.delete and deleteSessionSubmissions.

Recipe: SSO terminator (Cloudflare Access, IAP, Cognito)

When fabric-harness runs behind an SSO terminator, the terminator validates the user and forwards a verified header. Trust the header inside the resolver:

await startDevServer({
  extractAuthToken: (req) => {
    const userHeader = req.headers['cf-access-authenticated-user-email'];
    if (typeof userHeader === 'string' && userHeader.length > 0) return true;
    return undefined;
  },
});

Make sure the terminator strips the header from inbound requests that didn't pass through it — otherwise clients can spoof.

Recipe: per-tenant cookies

Combine with X-Fabric-Tenant header to scope cookie validation per tenant:

extractAuthToken: (req) => {
  const tenant = req.headers['x-fabric-tenant'];
  const cookie = parseCookie(req, `session_${tenant}`);
  return cookie ? verifyForTenant(cookie, tenant) : undefined;
},

WebSocket cookies (browsers)

Same-origin WebSocket upgrades carry browser cookies automatically — extractAuthToken reads them just like HTTP. Cross-origin? Sec-WebSocket-Protocol workarounds exist but are clunky; recommend bearer-via-query-param (?token=...) for cross-origin WS.

See also