chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
# @trigger.dev/plugins
|
||||
|
||||
## 4.5.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@4.5.2`
|
||||
|
||||
## 4.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Extend the SSO plugin contract with WorkOS Directory Sync (SCIM) support. ([#4148](https://github.com/triggerdotdev/trigger.dev/pull/4148))
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@4.5.1`
|
||||
|
||||
## 4.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- The public interfaces for a plugin system. Initially consolidated authentication and authorization interfaces. ([#3499](https://github.com/triggerdotdev/trigger.dev/pull/3499))
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@4.5.0`
|
||||
|
||||
## 4.5.0-rc.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@4.5.0-rc.7`
|
||||
|
||||
## 4.5.0-rc.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@4.5.0-rc.6`
|
||||
|
||||
## 4.5.0-rc.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@4.5.0-rc.5`
|
||||
|
||||
## 4.5.0-rc.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@4.5.0-rc.4`
|
||||
|
||||
## 4.5.0-rc.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@4.5.0-rc.3`
|
||||
|
||||
## 4.5.0-rc.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@4.5.0-rc.2`
|
||||
|
||||
## 4.5.0-rc.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@4.5.0-rc.1`
|
||||
|
||||
## 4.5.0-rc.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- The public interfaces for a plugin system. Initially consolidated authentication and authorization interfaces. ([#3499](https://github.com/triggerdotdev/trigger.dev/pull/3499))
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@4.5.0-rc.0`
|
||||
|
||||
## 0.0.0-prerelease-20260506134321
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b3a967765: The public interfaces for a plugin system. Initially consolidated authentication and authorization interfaces.
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@trigger.dev/plugins",
|
||||
"version": "4.5.2",
|
||||
"description": "Plugin contracts and interfaces for Trigger.dev",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/triggerdotdev/trigger.dev",
|
||||
"directory": "packages/plugins"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
"neverthrow": "^8.2.0"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.0",
|
||||
"rimraf": "6.0.1",
|
||||
"tsup": "^8.4.0",
|
||||
"typescript": "^5.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.20.0"
|
||||
},
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/index.ts",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Database connections for a plugin that owns its own client. The host
|
||||
// resolves the URLs from its env so the plugin follows the same
|
||||
// writer/replica topology the host uses. Shared by every plugin contract
|
||||
// that carries a `database` section.
|
||||
export type PluginDatabaseConfig = {
|
||||
// Primary (writer) connection URL. Mutations and read-your-writes
|
||||
// management reads run here.
|
||||
writerUrl: string;
|
||||
// Read-replica URL for the per-request auth reads. Omitted → those reads
|
||||
// share the writer connection.
|
||||
readerUrl?: string;
|
||||
// Per-process pool sizes. Omitted → plugin defaults (writer 2, reader 5).
|
||||
writerConnectionLimit?: number;
|
||||
readerConnectionLimit?: number;
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
export type {
|
||||
RoleBasedAccessControlPlugin,
|
||||
RoleBaseAccessController,
|
||||
RoleAssignmentResult,
|
||||
RoleMutationResult,
|
||||
Permission,
|
||||
Role,
|
||||
RbacAbility,
|
||||
RbacSubject,
|
||||
RbacResource,
|
||||
RbacEnvironment,
|
||||
RbacUser,
|
||||
BearerAuthResult,
|
||||
SessionAuthResult,
|
||||
PatAuthResult,
|
||||
UserActorAuthResult,
|
||||
UserActorClaims,
|
||||
RbacPluginConfig,
|
||||
RbacDatabaseConfig,
|
||||
SystemRole,
|
||||
AuthenticatedEnvironment,
|
||||
} from "./rbac.js";
|
||||
|
||||
export { buildJwtAbility } from "./rbac.js";
|
||||
export {
|
||||
isUserActorToken,
|
||||
signUserActorToken,
|
||||
verifyUserActorToken,
|
||||
USER_ACTOR_TOKEN_PREFIX,
|
||||
} from "./rbac.js";
|
||||
|
||||
export type { PluginDatabaseConfig } from "./databaseConfig.js";
|
||||
|
||||
export type {
|
||||
SsoPlugin,
|
||||
SsoPluginConfig,
|
||||
SsoController,
|
||||
OrgSsoStatus,
|
||||
SsoRouteDecision,
|
||||
SsoFlow,
|
||||
SsoProfile,
|
||||
SsoConnectionState,
|
||||
SsoDomainState,
|
||||
SsoDomainStatus,
|
||||
SsoResolutionDecision,
|
||||
SsoDecisionError,
|
||||
SsoBeginError,
|
||||
SsoCompleteError,
|
||||
SsoMutationError,
|
||||
SsoPortalError,
|
||||
SsoValidateError,
|
||||
SsoWebhookError,
|
||||
SsoWebhookEvent,
|
||||
DirectoryState,
|
||||
DirectoryGroupMapping,
|
||||
DirectorySyncStatus,
|
||||
DirectorySyncEffect,
|
||||
} from "./sso.js";
|
||||
|
||||
export { SSO_FLOWS } from "./sso.js";
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* Plugin-owned metadata for a built-in system role. The plugin returns
|
||||
* these in canonical order (highest authority first) so the dashboard
|
||||
* can render columns / build a level ladder without knowing role names.
|
||||
*
|
||||
* Roles the plugin chooses not to expose at all (e.g. seeded but hidden)
|
||||
* are not returned by `systemRoles()` — there's no "advertised but
|
||||
* absent" state.
|
||||
*
|
||||
* `available` indicates whether the role is assignable on the *org's
|
||||
* plan*. v1: Free/Hobby plans get Owner+Admin available; Pro+ adds
|
||||
* Developer. Consumers may render unavailable rows with an upgrade
|
||||
* badge, hide them, or otherwise gate UI on the flag.
|
||||
*/
|
||||
export type SystemRole = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
export type Permission = {
|
||||
// `<action>:<subject>` — display name, derived from the ability rule.
|
||||
name: string;
|
||||
description: string;
|
||||
// Display bucket for the Roles page (e.g. "Runs", "Tasks"). The page
|
||||
// groups permissions by this string and lists groups in the order they
|
||||
// first appear in `allPermissions()`, so the plugin owns both the
|
||||
// bucket label and the section ordering. Omit for "no grouping".
|
||||
group?: string;
|
||||
// Inverted (deny) rules surface as ✗ in the Roles page.
|
||||
inverted?: boolean;
|
||||
// Rule conditions (e.g. `{ envType: "PRODUCTION" }`) — when present,
|
||||
// the Roles page renders a tier badge alongside the permission row.
|
||||
conditions?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type Role = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
permissions: Permission[];
|
||||
isSystem: boolean;
|
||||
};
|
||||
|
||||
export type RbacSubject =
|
||||
| { type: "user"; userId: string; organizationId: string; projectId?: string }
|
||||
| { type: "personalAccessToken"; tokenId: string; organizationId: string; projectId?: string }
|
||||
| { type: "publicJWT"; environmentId: string; organizationId: string; projectId?: string }
|
||||
// Delegated user-actor token (`tr_uat_…`): a short-lived, stateless
|
||||
// credential that authenticates as `userId`. `client` records what minted
|
||||
// it (e.g. a dashboard agent) for attribution.
|
||||
| {
|
||||
type: "userActor";
|
||||
userId: string;
|
||||
client?: string;
|
||||
organizationId: string;
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
export type RbacResource = {
|
||||
type: string;
|
||||
id?: string;
|
||||
// Extra fields a route may pass for condition-based ability checks —
|
||||
// e.g. `envType` for env-tier-scoped rules ("Member can read envvars
|
||||
// unless envType === 'PRODUCTION'"). The plugin's ability matcher
|
||||
// reads these off the resource object; routes that don't use
|
||||
// conditional rules can keep passing `{ type, id? }`.
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
// The plugin contract carries the same env shape that host webapps' auth
|
||||
// flows use. Defined in @trigger.dev/core so it's importable from any
|
||||
// internal package without going through the plugin contract itself.
|
||||
export type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment";
|
||||
import type { AuthenticatedEnvironment as RbacEnv } from "@trigger.dev/core/v3/auth/environment";
|
||||
import { generateJWT, validateJWT } from "@trigger.dev/core/v3/jwt";
|
||||
|
||||
/** @deprecated Renamed to `AuthenticatedEnvironment`. Kept as alias for transitional code. */
|
||||
export type RbacEnvironment = RbacEnv;
|
||||
|
||||
export type RbacUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
admin: boolean;
|
||||
confirmedBasicDetails: boolean;
|
||||
isImpersonating: boolean;
|
||||
};
|
||||
|
||||
/** Pre-built ability returned by authenticate* — all checks are sync, no DB call. */
|
||||
export interface RbacAbility {
|
||||
// Array form means "grant access if any resource in the array passes" —
|
||||
// used by routes that touch multiple resources (e.g. a run also carries
|
||||
// a batch id, tags, a task identifier) so a JWT scoped to any of them
|
||||
// grants access.
|
||||
can(action: string, resource: RbacResource | RbacResource[]): boolean;
|
||||
canSuper(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an ability from JWT scope strings like "read:runs",
|
||||
* "read:runs:run_abc", "read:all", "admin".
|
||||
*
|
||||
* This is the single source of truth for interpreting public-token scope
|
||||
* strings. Both the host's built-in fallback and any auth plugin import it
|
||||
* from here so a token minted by the host is decoded identically no matter
|
||||
* which auth path serves the request — two copies of this grammar would
|
||||
* drift, and the difference would silently change what a token grants.
|
||||
*/
|
||||
export function buildJwtAbility(scopes: string[]): RbacAbility {
|
||||
const matches = (action: string, r: RbacResource): boolean =>
|
||||
scopes.some((scope) => {
|
||||
// Only the first two colons are delimiters — everything after the
|
||||
// second colon is the resource id (which may itself contain colons,
|
||||
// e.g. user-provided tags like "env:staging"). Naive
|
||||
// `split(":")` + 3-tuple destructuring truncated such ids to the
|
||||
// first segment and silently failed to match.
|
||||
const parts = scope.split(":");
|
||||
const scopeAction = parts[0];
|
||||
const scopeType = parts[1];
|
||||
const scopeId = parts.length > 2 ? parts.slice(2).join(":") : undefined;
|
||||
// Bare `admin` is the universal wildcard. `admin:<type>` is *not* —
|
||||
// it falls through to normal matching as action="admin" against
|
||||
// resources of that type. Treating `admin:<anything>` as universal
|
||||
// would silently broaden any such tokens beyond the narrow,
|
||||
// route-listed grant they had before scope-based abilities.
|
||||
if (scopeAction === "admin" && !scopeType) return true;
|
||||
if (scopeAction !== action && scopeAction !== "*") return false;
|
||||
if (scopeType === "all") return true;
|
||||
if (scopeType !== r.type) return false;
|
||||
if (!scopeId) return true;
|
||||
return scopeId === r.id;
|
||||
});
|
||||
return {
|
||||
can(action: string, resource: RbacResource | RbacResource[]): boolean {
|
||||
// Array form means "any element passes → authorized", matching the
|
||||
// legacy multi-key authorization semantic.
|
||||
return Array.isArray(resource)
|
||||
? resource.some((r) => matches(action, r))
|
||||
: matches(action, resource);
|
||||
},
|
||||
canSuper(): boolean {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Delegated user-actor token grammar ───────────────────────────────────
|
||||
//
|
||||
// A `tr_uat_…` token is the JWT body (signed HS256 with the platform secret)
|
||||
// behind a routing prefix. Single source of truth for minting/verifying so
|
||||
// the host, its fallback, and any auth plugin agree on the wire format — same
|
||||
// reasoning as `buildJwtAbility`. Built on core's `generateJWT`/`validateJWT`
|
||||
// so it shares the issuer/audience/alg the rest of the platform's JWTs use.
|
||||
|
||||
export const USER_ACTOR_TOKEN_PREFIX = "tr_uat_";
|
||||
// Distinguishes a UAT from other platform-secret-signed JWTs.
|
||||
const USER_ACTOR_KIND = "user_actor";
|
||||
|
||||
export type UserActorClaims = {
|
||||
userId: string;
|
||||
client?: string;
|
||||
sessionId?: string;
|
||||
// Optional scope cap (e.g. `["read:runs"]`) — ceilings the token below the
|
||||
// user's role. Absent today; the auth path is already cap-ready.
|
||||
cap?: string[];
|
||||
};
|
||||
|
||||
export function isUserActorToken(token: string): boolean {
|
||||
return token.startsWith(USER_ACTOR_TOKEN_PREFIX);
|
||||
}
|
||||
|
||||
export async function signUserActorToken(
|
||||
secret: string,
|
||||
opts: {
|
||||
userId: string;
|
||||
client: string;
|
||||
sessionId?: string;
|
||||
cap?: string[];
|
||||
expirationTime?: string | number | Date;
|
||||
}
|
||||
): Promise<string> {
|
||||
const jwt = await generateJWT({
|
||||
secretKey: secret,
|
||||
payload: {
|
||||
kind: USER_ACTOR_KIND,
|
||||
sub: opts.userId,
|
||||
act: { client: opts.client, ...(opts.sessionId ? { sessionId: opts.sessionId } : {}) },
|
||||
...(opts.cap ? { cap: opts.cap } : {}),
|
||||
},
|
||||
expirationTime: opts.expirationTime ?? "1h",
|
||||
});
|
||||
return `${USER_ACTOR_TOKEN_PREFIX}${jwt}`;
|
||||
}
|
||||
|
||||
// undefined for anything that isn't a valid, unexpired, correctly-signed UAT.
|
||||
export async function verifyUserActorToken(
|
||||
secret: string,
|
||||
token: string
|
||||
): Promise<UserActorClaims | undefined> {
|
||||
if (!isUserActorToken(token)) return;
|
||||
|
||||
const result = await validateJWT(token.slice(USER_ACTOR_TOKEN_PREFIX.length), secret);
|
||||
if (!result.ok) return;
|
||||
|
||||
const payload = result.payload;
|
||||
if (payload.kind !== USER_ACTOR_KIND || typeof payload.sub !== "string") return;
|
||||
|
||||
const act = payload.act as { client?: string; sessionId?: string } | undefined;
|
||||
return {
|
||||
userId: payload.sub,
|
||||
client: act?.client,
|
||||
sessionId: act?.sessionId,
|
||||
cap: Array.isArray(payload.cap) ? (payload.cap as string[]) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export type BearerAuthResult =
|
||||
| { ok: false; status: 401 | 403; error: string }
|
||||
| {
|
||||
ok: true;
|
||||
environment: RbacEnv;
|
||||
subject: RbacSubject;
|
||||
ability: RbacAbility;
|
||||
// `act` carries the acting user (`act.sub`) when the public JWT was
|
||||
// minted from a PAT/UAT exchange that stamped a delegation claim. Hosts
|
||||
// surface it for attribution (e.g. who resolved an error).
|
||||
jwt?: { realtime?: { skipColumns?: string[] }; oneTimeUse?: boolean; act?: { sub: string } };
|
||||
};
|
||||
|
||||
export type SessionAuthResult =
|
||||
| { ok: false; reason: "unauthenticated" | "unauthorized" }
|
||||
| { ok: true; user: RbacUser; subject: RbacSubject; ability: RbacAbility };
|
||||
|
||||
// PAT auth deliberately omits `environment` — PATs are user identity
|
||||
// tokens, not environment tokens. The ability is resolved per-request
|
||||
// from the user's role in the target org (passed via `context`),
|
||||
// intersected with the PAT's optional max-role cap.
|
||||
export type PatAuthResult =
|
||||
| { ok: false; status: 401 | 403; error: string }
|
||||
| {
|
||||
ok: true;
|
||||
tokenId: string;
|
||||
userId: string;
|
||||
// The token's stored `lastAccessedAt`, returned alongside the
|
||||
// identity so the host can throttle the per-request update in JS
|
||||
// (skip the DB roundtrip when the value is fresh). Plugins must
|
||||
// include this column in their auth lookup; the host owns the
|
||||
// throttle window + the UPDATE itself. Null on a never-accessed
|
||||
// token. The plugin contract requires this so the apiBuilder can
|
||||
// collapse PAT auth + lastAccessedAt update from 2 queries to 1
|
||||
// in the fresh-cache case — matching pre-RBAC main's query count.
|
||||
lastAccessedAt: Date | null;
|
||||
subject: RbacSubject;
|
||||
ability: RbacAbility;
|
||||
};
|
||||
|
||||
// Like PatAuthResult but stateless — a UAT has no stored row, so there's no
|
||||
// `tokenId`/`lastAccessedAt`. The ability is `min(user's role in the target
|
||||
// org, the token's optional scope cap)`, same cap-and-floor model as PATs.
|
||||
export type UserActorAuthResult =
|
||||
| { ok: false; status: 401 | 403; error: string }
|
||||
| {
|
||||
ok: true;
|
||||
userId: string;
|
||||
subject: RbacSubject;
|
||||
ability: RbacAbility;
|
||||
};
|
||||
|
||||
export interface RoleBaseAccessController {
|
||||
// True when a real RBAC plugin is loaded; false when the built-in
|
||||
// fallback is in use. Hosts gate behaviour that's only meaningful
|
||||
// when the plugin is present (e.g. skipping role-attachment writes,
|
||||
// hiding role-pickers in the UI, branching on whether ability checks
|
||||
// are authoritative or permissive).
|
||||
isUsingPlugin(): Promise<boolean>;
|
||||
|
||||
// API routes (Bearer token): one DB query → identity + pre-built ability
|
||||
// options.allowJWT: when true, accepts PUBLIC_JWT tokens in addition to environment API keys
|
||||
authenticateBearer(request: Request, options?: { allowJWT?: boolean }): Promise<BearerAuthResult>;
|
||||
|
||||
// Dashboard loaders/actions (session cookie): one DB query → user + pre-built ability.
|
||||
// The caller resolves `userId` from the session cookie and passes it in.
|
||||
// (`null` means "no authenticated user"; the plugin returns `{ ok: false,
|
||||
// reason: "unauthenticated" }`.) The plugin used to take a
|
||||
// `helpers.getSessionUserId(request)` callback at create-time; pulling the
|
||||
// userId resolution into the caller drops a static module-load coupling
|
||||
// from the plugin's host module to the host's session-cookie code.
|
||||
authenticateSession(
|
||||
request: Request,
|
||||
context: { userId: string | null; organizationId?: string; projectId?: string }
|
||||
): Promise<SessionAuthResult>;
|
||||
|
||||
// PAT-authenticated routes (Authorization: Bearer tr_pat_…). The token
|
||||
// identifies the user; the effective ability is `min(user's current
|
||||
// role in the target org, the PAT's optional max-role cap)`. The user's
|
||||
// actual org membership is the floor — if they've been demoted or
|
||||
// removed, the PAT auto-narrows. The cap is set at PAT creation and
|
||||
// ceilings the token even when the user is more privileged.
|
||||
//
|
||||
// No plugin installed → fallback returns a permissive ability so PAT
|
||||
// routes that don't yet declare an `authorization` block keep working
|
||||
// exactly as they did pre-RBAC.
|
||||
authenticatePat(
|
||||
request: Request,
|
||||
context: { organizationId?: string; projectId?: string }
|
||||
): Promise<PatAuthResult>;
|
||||
|
||||
// Delegated user-actor token routes (Authorization: Bearer tr_uat_…). The
|
||||
// plugin verifies the token itself (stateless — no DB lookup of the token)
|
||||
// and resolves the same cap-and-floor ability a PAT would for the same
|
||||
// user: floor = the user's role in the target org (rejects non-members,
|
||||
// like authenticatePat), cap = the token's optional scope cap.
|
||||
//
|
||||
// No plugin installed → fallback verifies the token and returns a
|
||||
// permissive ability, mirroring the fallback's PAT behaviour.
|
||||
authenticateUserActor(
|
||||
request: Request,
|
||||
context: { organizationId?: string; projectId?: string }
|
||||
): Promise<UserActorAuthResult>;
|
||||
|
||||
// Convenience: authenticate + ability.can() check in one call; returns ok:false if check fails.
|
||||
// resource accepts the same single-or-array shape as RbacAbility.can — array form means
|
||||
// "grant access if any element passes".
|
||||
authenticateAuthorizeBearer(
|
||||
request: Request,
|
||||
check: { action: string; resource: RbacResource | RbacResource[] },
|
||||
options?: { allowJWT?: boolean }
|
||||
): Promise<BearerAuthResult>;
|
||||
|
||||
authenticateAuthorizeSession(
|
||||
request: Request,
|
||||
context: { userId: string | null; organizationId?: string; projectId?: string },
|
||||
check: { action: string; resource: RbacResource | RbacResource[] }
|
||||
): Promise<SessionAuthResult>;
|
||||
|
||||
// Plugin-owned catalogue of built-in system roles for the given org,
|
||||
// in canonical order (highest authority first). Returns null when no
|
||||
// plugin is installed — there are no seeded roles to refer to in that
|
||||
// case (the default fallback's `allRoles` returns []).
|
||||
//
|
||||
// Hidden roles (e.g. Member in v1) are filtered out entirely. Each
|
||||
// entry's `available` flag reflects whether the org's plan permits
|
||||
// assigning that role; consumers can render unavailable entries with
|
||||
// an upgrade badge or hide them.
|
||||
systemRoles(organizationId: string): Promise<SystemRole[] | null>;
|
||||
|
||||
// Role introspection. The fallback returns []; a plugin may return
|
||||
// its own role catalogue.
|
||||
allPermissions(organizationId: string): Promise<Permission[]>;
|
||||
allRoles(organizationId: string): Promise<Role[]>;
|
||||
|
||||
// Of the roles returned by `allRoles(organizationId)`, which IDs may
|
||||
// be assigned right now? Used by the Teams page UI to disable
|
||||
// role-dropdown options the org isn't allowed to assign. The default
|
||||
// fallback returns every role id (permissive — it doesn't apply any
|
||||
// gating). Server-side enforcement lives in setUserRole; this method
|
||||
// is purely a UI affordance.
|
||||
getAssignableRoleIds(organizationId: string): Promise<string[]>;
|
||||
|
||||
// Role management. Mutation methods return a discriminated Result
|
||||
// rather than throwing — the dashboard surfaces `error` strings
|
||||
// directly to the user (system role edits, gating, validation
|
||||
// conflicts), so a thrown exception is only ever for unexpected
|
||||
// failures (DB outage, bug). The default fallback returns
|
||||
// `{ ok: false, error: "RBAC plugin not installed" }` for these.
|
||||
createRole(params: {
|
||||
organizationId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
permissions: string[];
|
||||
}): Promise<RoleMutationResult>;
|
||||
|
||||
updateRole(params: {
|
||||
roleId: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
permissions?: string[];
|
||||
}): Promise<RoleMutationResult>;
|
||||
|
||||
deleteRole(roleId: string): Promise<RoleAssignmentResult>;
|
||||
|
||||
// Role assignments. Same Result discipline as the role-management
|
||||
// methods above. The default fallback returns
|
||||
// `{ ok: false, error: "RBAC plugin not installed" }`.
|
||||
getUserRole(params: {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
projectId?: string;
|
||||
}): Promise<Role | null>;
|
||||
|
||||
// Batch variant for callers that need per-user roles for many users
|
||||
// in one round-trip (e.g. the Team page rendering N members).
|
||||
// Org-scoped only — project-scoped reads still go through getUserRole.
|
||||
// Returns a Map keyed by userId; users with no resolvable role map to
|
||||
// null. The default fallback returns a Map of all userIds → null.
|
||||
getUserRoles(userIds: string[], organizationId: string): Promise<Map<string, Role | null>>;
|
||||
|
||||
setUserRole(params: {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
roleId: string;
|
||||
projectId?: string;
|
||||
}): Promise<RoleAssignmentResult>;
|
||||
|
||||
removeUserRole(params: {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
projectId?: string;
|
||||
}): Promise<RoleAssignmentResult>;
|
||||
|
||||
getTokenRole(tokenId: string): Promise<Role | null>;
|
||||
setTokenRole(params: { tokenId: string; roleId: string }): Promise<RoleAssignmentResult>;
|
||||
removeTokenRole(tokenId: string): Promise<RoleAssignmentResult>;
|
||||
}
|
||||
|
||||
// Mutation result for role create/update — success carries the new
|
||||
// `role`, failure carries a user-facing `error` string.
|
||||
export type RoleMutationResult = { ok: true; role: Role } | { ok: false; error: string };
|
||||
|
||||
// Result for assignment / deletion mutations that don't return a value.
|
||||
// `code` is an optional machine-readable reason so callers can branch on
|
||||
// expected outcomes (e.g. `last_owner`, the guard that keeps an org from
|
||||
// losing its final Owner) instead of matching the free-text `error`.
|
||||
export type RoleAssignmentErrorCode = "last_owner";
|
||||
export type RoleAssignmentResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: string; code?: RoleAssignmentErrorCode };
|
||||
|
||||
import type { PluginDatabaseConfig } from "./databaseConfig.js";
|
||||
|
||||
// Host-injected configuration the plugin can't read from the environment
|
||||
// itself (the plugin runs in the host's process but owns no env contract).
|
||||
export type RbacPluginConfig = {
|
||||
// Platform secret the host signs user-actor tokens with; the plugin uses
|
||||
// it to verify them in `authenticateUserActor`. Omitted → UAT auth 401s.
|
||||
userActorSecret?: string;
|
||||
// Database connections for a plugin that owns its own client. Omitted →
|
||||
// the plugin falls back to its own defaults.
|
||||
database?: PluginDatabaseConfig;
|
||||
};
|
||||
|
||||
export type { PluginDatabaseConfig as RbacDatabaseConfig } from "./databaseConfig.js";
|
||||
|
||||
export interface RoleBasedAccessControlPlugin {
|
||||
create(config?: RbacPluginConfig): RoleBaseAccessController | Promise<RoleBaseAccessController>;
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import type { ResultAsync } from "neverthrow";
|
||||
import type { PluginDatabaseConfig } from "./databaseConfig.js";
|
||||
|
||||
// === Domain types ===
|
||||
|
||||
export type SsoConnectionState = "active" | "inactive";
|
||||
|
||||
export type SsoDomainState = "pending" | "verified" | "failed";
|
||||
|
||||
export type SsoDomainStatus = {
|
||||
domain: string;
|
||||
verified: boolean;
|
||||
state: SsoDomainState;
|
||||
// Vendor-supplied reason code present when state === "failed".
|
||||
// Plugin keeps it opaque; the host UI surfaces it to the admin so
|
||||
// they know which knob to turn before retrying verification.
|
||||
verificationFailedReason: string | null;
|
||||
};
|
||||
|
||||
export type OrgSsoStatus = {
|
||||
hasIdpOrg: boolean;
|
||||
enforced: boolean;
|
||||
jitProvisioningEnabled: boolean;
|
||||
jitDefaultRoleId: string | null;
|
||||
idpOrgId: string | null;
|
||||
primaryConnectionId: string | null;
|
||||
domains: ReadonlyArray<SsoDomainStatus>;
|
||||
connections: ReadonlyArray<{
|
||||
id: string;
|
||||
name: string | null;
|
||||
connectionType: string;
|
||||
state: SsoConnectionState;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type SsoRouteDecision = { kind: "no_sso" } | { kind: "sso_required"; idpOrgId: string };
|
||||
|
||||
export const SSO_FLOWS = [
|
||||
"user_initiated",
|
||||
"auto_discovery_magic",
|
||||
"auto_discovery_oauth",
|
||||
"auto_discovery_vercel",
|
||||
"idp_initiated",
|
||||
] as const;
|
||||
|
||||
export type SsoFlow = (typeof SSO_FLOWS)[number];
|
||||
|
||||
export type SsoProfile = {
|
||||
// Lowercase-normalized at the plugin / host boundary.
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
idpSubjectId: string;
|
||||
idpOrgId: string;
|
||||
idpConnectionId: string;
|
||||
};
|
||||
|
||||
export type SsoResolutionDecision =
|
||||
| { kind: "existing_user_by_idp"; userId: string }
|
||||
| { kind: "linked_by_email"; userId: string }
|
||||
| { kind: "create_new_user"; profile: SsoProfile };
|
||||
|
||||
// === Directory sync (SCIM) domain types ===
|
||||
|
||||
export type DirectoryState = "active" | "inactive";
|
||||
|
||||
// A directory group and the role it grants. `mappedRoleId === null` means the
|
||||
// group has no role mapping; its members get no role from this group (and are
|
||||
// not provisioned on its account alone).
|
||||
export type DirectoryGroupMapping = {
|
||||
groupId: string;
|
||||
name: string;
|
||||
mappedRoleId: string | null;
|
||||
};
|
||||
|
||||
export type DirectorySyncStatus = {
|
||||
hasDirectory: boolean;
|
||||
// True when at least one directory is in the "active" state.
|
||||
hasActiveDirectory: boolean;
|
||||
// Per-org override: when true, directory users whose email domain is NOT a
|
||||
// verified org domain are still provisioned. Default false.
|
||||
allowExternalDomainSync: boolean;
|
||||
// Raw per-org setting (for the settings toggle). When false AND a directory
|
||||
// is active, manual membership adds/removes are blocked (see
|
||||
// getMembershipPolicy for the effective value). Default true.
|
||||
allowManualMembership: boolean;
|
||||
// Role assigned to directory users who are active but belong to no mapped
|
||||
// group. `null` (default) means ungrouped users are NOT provisioned; set a
|
||||
// role to provision them at it (on verified domains, subject to the
|
||||
// external-domain setting).
|
||||
directoryDefaultRoleId: string | null;
|
||||
userCount: number;
|
||||
directories: ReadonlyArray<{
|
||||
id: string;
|
||||
name: string | null;
|
||||
type: string;
|
||||
state: DirectoryState;
|
||||
}>;
|
||||
groups: ReadonlyArray<DirectoryGroupMapping>;
|
||||
};
|
||||
|
||||
// A host-actionable membership mutation derived from a directory-sync event.
|
||||
// The plugin owns all `enterprise.*` writes; these effects describe the
|
||||
// `public.*` (User / OrgMember / role / token) writes the host must perform —
|
||||
// the plugin never touches those tables. The host worker applies them
|
||||
// idempotently.
|
||||
//
|
||||
// - `provision`: ensure the User exists (create when `userId === null`),
|
||||
// ensure the OrgMember exists, and set its role.
|
||||
// - `deprovision`: remove the membership (guarded against last-Owner), force
|
||||
// logout, and revoke tokens per host policy.
|
||||
// - `set_role`: overwrite the member's role (directory-authoritative).
|
||||
export type DirectorySyncEffect =
|
||||
| {
|
||||
kind: "provision";
|
||||
userId: string | null;
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
organizationId: string;
|
||||
roleId: string | null;
|
||||
}
|
||||
| { kind: "deprovision"; userId: string; organizationId: string }
|
||||
| { kind: "set_role"; userId: string; organizationId: string; roleId: string };
|
||||
|
||||
// === Errors ===
|
||||
|
||||
export type SsoDecisionError = "internal";
|
||||
|
||||
export type SsoBeginError = "no_org_for_domain" | "no_active_connection" | "feature_disabled";
|
||||
|
||||
export type SsoCompleteError =
|
||||
| "state_replayed_or_expired"
|
||||
| "state_invalid_signature"
|
||||
| "code_exchange_failed"
|
||||
| "org_mismatch"
|
||||
| "email_mismatch"
|
||||
| "connection_unknown";
|
||||
|
||||
export type SsoMutationError = "feature_disabled" | "rbac_role_invalid" | "internal";
|
||||
|
||||
// Vendor-neutral name for "the identity-provider organisation isn't available".
|
||||
export type SsoPortalError = "idp_org_unavailable" | "internal";
|
||||
|
||||
// The only failure a session re-validation can report is "internal" —
|
||||
// callers MUST treat it as fail-open (keep the session). An invalid
|
||||
// session is NOT an error: it's a successful result of `{ valid: false }`.
|
||||
export type SsoValidateError = "internal";
|
||||
|
||||
// Inbound webhook handling. `invalid_signature` → reject (4xx, no retry);
|
||||
// `feature_disabled` → no plugin installed (host returns 404); `internal`
|
||||
// → transient, the host returns 5xx so the provider retries.
|
||||
export type SsoWebhookError = "invalid_signature" | "feature_disabled" | "internal";
|
||||
|
||||
// A verified, JSON-serializable inbound event. Vendor-neutral envelope —
|
||||
// `event` is the provider's event-type string, `data` its opaque payload.
|
||||
export type SsoWebhookEvent = { id: string; event: string; data: unknown };
|
||||
|
||||
// === Controller ===
|
||||
|
||||
export interface SsoController {
|
||||
// True when a real SSO plugin is loaded. Hosts gate behaviour that's
|
||||
// only meaningful when the plugin is present (e.g. rendering the
|
||||
// settings tab, registering the SSO strategy actively).
|
||||
isUsingPlugin(): Promise<boolean>;
|
||||
|
||||
// --- Provisioning + admin UI ---
|
||||
|
||||
getStatus(organizationId: string): ResultAsync<OrgSsoStatus, SsoDecisionError>;
|
||||
|
||||
// Returns an admin-portal link the customer's IT admin uses to
|
||||
// configure their identity provider. First call also performs any lazy
|
||||
// initialization the plugin needs (no separate enable() method).
|
||||
generatePortalLink(params: {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
intent: "sso" | "domain_verification" | "dsync";
|
||||
returnUrl: string;
|
||||
}): ResultAsync<{ url: string }, SsoPortalError>;
|
||||
|
||||
setEnforced(params: {
|
||||
organizationId: string;
|
||||
enforced: boolean;
|
||||
}): ResultAsync<void, SsoMutationError>;
|
||||
|
||||
setJitProvisioningEnabled(params: {
|
||||
organizationId: string;
|
||||
enabled: boolean;
|
||||
}): ResultAsync<void, SsoMutationError>;
|
||||
|
||||
setJitDefaultRole(params: {
|
||||
organizationId: string;
|
||||
roleId: string | null;
|
||||
}): ResultAsync<void, SsoMutationError>;
|
||||
|
||||
// Atomic counterpart to the three setters above: the settings form
|
||||
// presents enforced + JIT-enabled + JIT-default-role as a single Save,
|
||||
// so they must commit all-or-nothing. Implementations write all three
|
||||
// OrgSsoConfig columns in one atomic write, so an `internal` failure
|
||||
// leaves none of the fields changed rather than a partially-applied
|
||||
// config. Prefer this over the individual setters for the admin Save path.
|
||||
updateConfig(params: {
|
||||
organizationId: string;
|
||||
enforced: boolean;
|
||||
jitProvisioningEnabled: boolean;
|
||||
jitDefaultRoleId: string | null;
|
||||
}): ResultAsync<void, SsoMutationError>;
|
||||
|
||||
// --- Directory sync (SCIM) admin UI ---
|
||||
|
||||
// Full directory-sync state for the settings section: directories, the
|
||||
// group→role mapping table, the external-domain toggle, and a member count.
|
||||
getDirectorySyncStatus(
|
||||
organizationId: string
|
||||
): ResultAsync<DirectorySyncStatus, SsoDecisionError>;
|
||||
|
||||
// Map a directory group to an RBAC role (or clear the mapping with null).
|
||||
// The role is validated against the org's assignable roles. Returns the
|
||||
// membership effects the change implies for the group's current members
|
||||
// (roles recomputed against the new mapping; deprovision when the group is
|
||||
// cleared and it was a member's last mapped group) — the host applies them
|
||||
// so a dashboard remap takes effect immediately, like a directory event.
|
||||
setDirectoryGroupRole(params: {
|
||||
organizationId: string;
|
||||
groupId: string;
|
||||
roleId: string | null;
|
||||
}): ResultAsync<{ effects: DirectorySyncEffect[] }, SsoMutationError>;
|
||||
|
||||
// Set the role for directory users with no mapped group (null = don't
|
||||
// provision ungrouped users). Owner is reserved and rejected.
|
||||
setDirectoryDefaultRole(params: {
|
||||
organizationId: string;
|
||||
roleId: string | null;
|
||||
}): ResultAsync<void, SsoMutationError>;
|
||||
|
||||
// Toggle whether directory users outside the org's verified domains are
|
||||
// provisioned. Default false (verified domains only).
|
||||
setAllowExternalDomainSync(params: {
|
||||
organizationId: string;
|
||||
allowed: boolean;
|
||||
}): ResultAsync<void, SsoMutationError>;
|
||||
|
||||
// --- Membership management policy + removal tombstones ---
|
||||
|
||||
// Effective policy for manual (dashboard) membership changes. Returns
|
||||
// `manualMembershipAllowed: false` only when the org set allow-manual off
|
||||
// AND a directory is active; otherwise true. Hosts use it to gate invite /
|
||||
// accept-invite / remove / self-leave and to hide those buttons.
|
||||
getMembershipPolicy(
|
||||
organizationId: string
|
||||
): ResultAsync<{ manualMembershipAllowed: boolean }, SsoDecisionError>;
|
||||
|
||||
setAllowManualMembership(params: {
|
||||
organizationId: string;
|
||||
allowed: boolean;
|
||||
}): ResultAsync<void, SsoMutationError>;
|
||||
|
||||
// Record that a user was removed from an org (manual removal or self-leave),
|
||||
// so passive SSO-JIT won't silently re-add them on next login. Idempotent
|
||||
// upsert keyed by (organizationId, userId). The host calls this after a
|
||||
// successful team removal; DSync deprovisions record their own tombstone
|
||||
// internally. No-op in the OSS fallback.
|
||||
recordMembershipRemoval(params: {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
reason: "manual_removal" | "self_leave";
|
||||
}): ResultAsync<void, SsoMutationError>;
|
||||
|
||||
// Clear a removal tombstone — a deliberate re-admission (the host calls this
|
||||
// when an invite is accepted). Idempotent; no-op when absent.
|
||||
clearMembershipRemoval(params: {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
}): ResultAsync<void, SsoMutationError>;
|
||||
|
||||
// --- Auth flow ---
|
||||
|
||||
// Called by every login entry point BEFORE the strategy proceeds.
|
||||
// Composite gate (plan tier + feature flags + config + enforced) is
|
||||
// implemented here. Fail-open: returns no_sso on internal error so a
|
||||
// plugin outage doesn't lock users out.
|
||||
decideRouteForEmail(email: string): ResultAsync<SsoRouteDecision, SsoDecisionError>;
|
||||
|
||||
// Returns the URL the user should be redirected to in order to
|
||||
// authenticate with their identity provider. Internally mints a
|
||||
// single-use signed state token; the implementation is opaque to
|
||||
// OSS callers. Email is lowercase-normalized before lookup.
|
||||
beginAuthorization(params: {
|
||||
email: string;
|
||||
redirectTo: string;
|
||||
flow: SsoFlow;
|
||||
}): ResultAsync<{ url: string }, SsoBeginError>;
|
||||
|
||||
// SP-initiated callback. Verifies and consumes the signed state token
|
||||
// single-use, exchanges the code with the SSO provider, cross-checks
|
||||
// the returned profile against the state claims. Returns profile +
|
||||
// state-carried redirectTo + flow.
|
||||
completeAuthorization(params: {
|
||||
code: string;
|
||||
state: string;
|
||||
}): ResultAsync<{ profile: SsoProfile; redirectTo: string; flow: SsoFlow }, SsoCompleteError>;
|
||||
|
||||
// IdP-initiated callback (no state). Validates the returned connection
|
||||
// identifier is one of ours. Default redirectTo is "/".
|
||||
completeIdpInitiatedAuthorization(params: {
|
||||
code: string;
|
||||
}): ResultAsync<{ profile: SsoProfile; redirectTo: string }, SsoCompleteError>;
|
||||
|
||||
// Re-validate a live SSO session against the IdP. Called periodically
|
||||
// (throttled by the host) for sessions that were established via SSO.
|
||||
// The available signal is whether the user's identity-provider
|
||||
// connection is still active, so `valid` reflects that. Returns an
|
||||
// `internal` error on any infrastructure failure (e.g. the identity
|
||||
// provider is unreachable) — the host MUST fail-open on the error and
|
||||
// only invalidate the session on an explicit `{ valid: false }`.
|
||||
validateSession(params: {
|
||||
userId: string;
|
||||
idpOrgId: string;
|
||||
connectionId: string;
|
||||
}): ResultAsync<{ valid: boolean }, SsoValidateError>;
|
||||
|
||||
// Look up an existing identity by IdP subject, or by lowercased email.
|
||||
// Returns a decision the OSS callback handler uses to drive
|
||||
// User/OrgMember writes. The plugin DOES NOT write to OSS public.*
|
||||
// tables — those writes are the host's responsibility.
|
||||
resolveSsoIdentity(params: {
|
||||
profile: SsoProfile;
|
||||
}): ResultAsync<SsoResolutionDecision, SsoMutationError>;
|
||||
|
||||
// After the host has created/found the User row, the plugin attaches
|
||||
// the IdP identity row in its own storage.
|
||||
attachSsoIdentity(params: {
|
||||
userId: string;
|
||||
profile: SsoProfile;
|
||||
}): ResultAsync<void, SsoMutationError>;
|
||||
|
||||
// Returns whether JIT should provision a membership for the given
|
||||
// (userId, idpOrgId), and the resolved roleId to assign (the org's
|
||||
// JIT default role, or null when no RBAC plugin is installed).
|
||||
// The host performs the actual OrgMember insert.
|
||||
evaluateJit(params: {
|
||||
userId: string;
|
||||
idpOrgId: string;
|
||||
}): ResultAsync<
|
||||
{ shouldProvision: boolean; organizationId: string; roleId: string | null },
|
||||
SsoMutationError
|
||||
>;
|
||||
|
||||
// --- Inbound webhooks ---
|
||||
|
||||
// Verify the signature of a raw inbound webhook request and return the
|
||||
// parsed, JSON-serializable event. The host forwards the raw body +
|
||||
// headers from a thin proxy route; the plugin owns the vendor-specific
|
||||
// signature scheme. The host enqueues the returned event for async
|
||||
// processing (it never enqueues an unverified request).
|
||||
verifyWebhook(params: {
|
||||
rawBody: string;
|
||||
headers: Record<string, string>;
|
||||
}): ResultAsync<{ event: SsoWebhookEvent }, SsoWebhookError>;
|
||||
|
||||
// Process a previously-verified webhook event (the host's background
|
||||
// worker calls this). Performs the plugin's own `enterprise.*` state writes
|
||||
// and returns any `public.*` membership effects the host must apply (empty
|
||||
// for SSO/domain/connection events; populated for directory-sync events).
|
||||
// Throws nothing — failures surface as `internal` so the worker retries;
|
||||
// effect application on the host side must be idempotent.
|
||||
processWebhookEvent(
|
||||
event: SsoWebhookEvent
|
||||
): ResultAsync<{ effects: DirectorySyncEffect[] }, SsoWebhookError>;
|
||||
}
|
||||
|
||||
// Host-injected configuration the plugin can't read from the environment
|
||||
// itself (the plugin runs in the host's process but owns no env contract).
|
||||
export type SsoPluginConfig = {
|
||||
// Database connections for a plugin that owns its own client. Omitted →
|
||||
// the plugin falls back to its own defaults.
|
||||
database?: PluginDatabaseConfig;
|
||||
};
|
||||
|
||||
export interface SsoPlugin {
|
||||
create(config?: SsoPluginConfig): SsoController | Promise<SsoController>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../.configs/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["./src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
treeshake: true,
|
||||
});
|
||||
Reference in New Issue
Block a user