forgeos 0.1.0-alpha.46 → 0.1.0-alpha.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +41 -0
- package/docs/changelog.md +31 -0
- package/package.json +1 -1
- package/src/forge/_generated/releaseManifest.json +1 -1
- package/src/forge/_generated/releaseManifest.ts +3 -3
- package/src/forge/cli/deploy.ts +62 -1
- package/src/forge/cli/main.ts +4 -0
- package/src/forge/cli/parse.ts +21 -3
- package/src/forge/cli/workos.ts +1581 -13
- package/src/forge/compiler/integration/templates/workos.ts +22 -0
- package/src/forge/version.ts +1 -1
package/src/forge/cli/workos.ts
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
|
-
import { basename, join } from "node:path";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import { GENERATED_DIR } from "../compiler/emitter/constants.ts";
|
|
7
7
|
import { stripDeterministicHeader } from "../compiler/primitives/header.ts";
|
|
8
8
|
|
|
9
|
-
export type WorkOSSubcommand = "install" | "doctor" | "seed" | "setup" | "prove";
|
|
9
|
+
export type WorkOSSubcommand = "install" | "doctor" | "seed" | "setup" | "prove" | "fga";
|
|
10
|
+
export type WorkOSFgaAction = "plan" | "sync" | "prove" | "doctor";
|
|
10
11
|
|
|
11
12
|
export interface WorkOSCommandOptions {
|
|
12
13
|
subcommand: WorkOSSubcommand;
|
|
14
|
+
fgaAction?: WorkOSFgaAction;
|
|
13
15
|
workspaceRoot: string;
|
|
14
16
|
json: boolean;
|
|
15
17
|
file?: string;
|
|
16
18
|
yes: boolean;
|
|
17
19
|
dryRun: boolean;
|
|
18
20
|
real?: boolean;
|
|
21
|
+
write?: boolean;
|
|
22
|
+
writePath?: string;
|
|
19
23
|
commandRunner?: WorkOSCommandRunner;
|
|
20
24
|
}
|
|
21
25
|
|
|
@@ -27,7 +31,7 @@ export interface WorkOSCheck {
|
|
|
27
31
|
|
|
28
32
|
export interface WorkOSCommandResult {
|
|
29
33
|
ok: boolean;
|
|
30
|
-
kind: "workos-install" | "workos-doctor" | "workos-seed" | "workos-setup" | "workos-prove";
|
|
34
|
+
kind: "workos-install" | "workos-doctor" | "workos-seed" | "workos-setup" | "workos-prove" | "workos-fga";
|
|
31
35
|
checks: WorkOSCheck[];
|
|
32
36
|
command?: string[];
|
|
33
37
|
applied?: boolean;
|
|
@@ -44,12 +48,15 @@ export type WorkOSCommandRunner = (
|
|
|
44
48
|
cwd: string;
|
|
45
49
|
encoding: "utf8";
|
|
46
50
|
stdio: ["ignore", "pipe", "pipe"];
|
|
51
|
+
env?: Record<string, string | undefined>;
|
|
47
52
|
},
|
|
48
53
|
) => { status: number | null; stdout: string; stderr: string };
|
|
49
54
|
|
|
50
55
|
const DEFAULT_SEED_FILE = "workos-seed.yml";
|
|
51
56
|
const GENERATED_SEED_FILE = `${GENERATED_DIR}/integrations/workos/workos-seed.yml`;
|
|
52
57
|
const WORKOS_SEED_STATE_FILE = ".workos-seed-state.json";
|
|
58
|
+
const WORKOS_FGA_STATE_FILE = ".workos-fga-state.json";
|
|
59
|
+
const WORKOS_FGA_SETUP_GUIDE_FILE = ".forge/workos-fga-setup.md";
|
|
53
60
|
|
|
54
61
|
function runExternalCommand(
|
|
55
62
|
command: string[],
|
|
@@ -60,6 +67,11 @@ function runExternalCommand(
|
|
|
60
67
|
cwd: options.workspaceRoot,
|
|
61
68
|
encoding: "utf8",
|
|
62
69
|
stdio: ["ignore", "pipe", "pipe"],
|
|
70
|
+
env: {
|
|
71
|
+
...process.env,
|
|
72
|
+
...readRealEnv(options.workspaceRoot),
|
|
73
|
+
WORKOS_MODE: process.env.WORKOS_MODE || "agent",
|
|
74
|
+
},
|
|
63
75
|
});
|
|
64
76
|
return {
|
|
65
77
|
status: result.status,
|
|
@@ -329,6 +341,207 @@ function workosJwksUri(clientId: string | undefined): string {
|
|
|
329
341
|
return clientId ? `https://api.workos.com/sso/jwks/${clientId}` : "https://api.workos.com/sso/jwks/<WORKOS_CLIENT_ID>";
|
|
330
342
|
}
|
|
331
343
|
|
|
344
|
+
export interface WorkOSCliAuthSummary {
|
|
345
|
+
required: boolean;
|
|
346
|
+
ok: boolean;
|
|
347
|
+
method: "api-key" | "cli";
|
|
348
|
+
skippedReason?: string;
|
|
349
|
+
statusCommand: string[];
|
|
350
|
+
loginCommand?: string[];
|
|
351
|
+
loginAttempted: boolean;
|
|
352
|
+
authenticated?: boolean;
|
|
353
|
+
email?: string;
|
|
354
|
+
userId?: string;
|
|
355
|
+
tokenExpired?: boolean;
|
|
356
|
+
hasRefreshToken?: boolean;
|
|
357
|
+
activeEnvironment?: unknown;
|
|
358
|
+
status?: number | null;
|
|
359
|
+
loginStatus?: number | null;
|
|
360
|
+
detail: string;
|
|
361
|
+
loginInstructions?: {
|
|
362
|
+
url?: string;
|
|
363
|
+
code?: string;
|
|
364
|
+
message?: string;
|
|
365
|
+
};
|
|
366
|
+
nextActions: string[];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function parseJsonObject(text: string): Record<string, unknown> | null {
|
|
370
|
+
try {
|
|
371
|
+
const parsed = JSON.parse(text);
|
|
372
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
373
|
+
? parsed as Record<string, unknown>
|
|
374
|
+
: null;
|
|
375
|
+
} catch {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function stringField(value: unknown): string | undefined {
|
|
381
|
+
return typeof value === "string" && value.trim() ? value : undefined;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function booleanField(value: unknown): boolean | undefined {
|
|
385
|
+
return typeof value === "boolean" ? value : undefined;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function extractWorkOSLoginInstructions(stdout: string, stderr: string): WorkOSCliAuthSummary["loginInstructions"] | undefined {
|
|
389
|
+
const output = `${stdout}\n${stderr}`.trim();
|
|
390
|
+
if (!output) {
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
const url = output.match(/https?:\/\/[^\s"'<>]+/)?.[0];
|
|
394
|
+
const code = output.match(/(?:code|verification code|user code)[^\w]*([A-Z0-9-]{4,})/i)?.[1]
|
|
395
|
+
?? output.match(/\b([A-Z0-9]{4}-[A-Z0-9]{4}|[A-Z0-9]{6,12})\b/)?.[1];
|
|
396
|
+
const message = output
|
|
397
|
+
.split(/\r?\n/)
|
|
398
|
+
.map((line) => line.trim())
|
|
399
|
+
.filter(Boolean)
|
|
400
|
+
.slice(0, 12)
|
|
401
|
+
.join("\n");
|
|
402
|
+
return {
|
|
403
|
+
...(url ? { url } : {}),
|
|
404
|
+
...(code ? { code } : {}),
|
|
405
|
+
...(message ? { message } : {}),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function workOSCliAuthCheck(auth: WorkOSCliAuthSummary): WorkOSCheck {
|
|
410
|
+
return {
|
|
411
|
+
name: "workos-cli-auth",
|
|
412
|
+
ok: auth.ok,
|
|
413
|
+
detail: auth.detail,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function ensureWorkOSCliAuthForHosted(options: WorkOSCommandOptions): WorkOSCliAuthSummary {
|
|
418
|
+
const env = readRealEnv(options.workspaceRoot);
|
|
419
|
+
const statusCommand = ["npx", "--yes", "workos@latest", "auth", "status", "--json"];
|
|
420
|
+
if (hasValue(env, "WORKOS_API_KEY")) {
|
|
421
|
+
return {
|
|
422
|
+
required: false,
|
|
423
|
+
ok: true,
|
|
424
|
+
method: "api-key",
|
|
425
|
+
skippedReason: "WORKOS_API_KEY is present; WorkOS CLI browser login is optional for hosted setup",
|
|
426
|
+
statusCommand,
|
|
427
|
+
loginAttempted: false,
|
|
428
|
+
detail: "WORKOS_API_KEY is present; hosted WorkOS setup can use API key authentication",
|
|
429
|
+
nextActions: [],
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const status = runExternalCommand(statusCommand, options);
|
|
434
|
+
const parsed = parseJsonObject(status.stdout);
|
|
435
|
+
const authenticated = booleanField(parsed?.authenticated) ?? false;
|
|
436
|
+
const tokenExpired = booleanField(parsed?.tokenExpired) ?? false;
|
|
437
|
+
const hasRefreshToken = booleanField(parsed?.hasRefreshToken) ?? false;
|
|
438
|
+
const email = stringField(parsed?.email);
|
|
439
|
+
const userId = stringField(parsed?.userId);
|
|
440
|
+
const activeEnvironment = parsed?.activeEnvironment;
|
|
441
|
+
if (status.status === 0 && authenticated && (!tokenExpired || hasRefreshToken)) {
|
|
442
|
+
return {
|
|
443
|
+
required: true,
|
|
444
|
+
ok: true,
|
|
445
|
+
method: "cli",
|
|
446
|
+
statusCommand,
|
|
447
|
+
loginAttempted: false,
|
|
448
|
+
authenticated,
|
|
449
|
+
...(email ? { email } : {}),
|
|
450
|
+
...(userId ? { userId } : {}),
|
|
451
|
+
tokenExpired,
|
|
452
|
+
hasRefreshToken,
|
|
453
|
+
...(activeEnvironment ? { activeEnvironment } : {}),
|
|
454
|
+
status: status.status,
|
|
455
|
+
detail: tokenExpired && hasRefreshToken
|
|
456
|
+
? `WorkOS CLI is authenticated${email ? ` as ${email}` : ""}; token is expired but a refresh token is available`
|
|
457
|
+
: `WorkOS CLI is authenticated${email ? ` as ${email}` : ""}`,
|
|
458
|
+
nextActions: [],
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const loginCommand = ["npx", "--yes", "workos@latest", "auth", "login", "--json"];
|
|
463
|
+
const login = runExternalCommand(loginCommand, options);
|
|
464
|
+
const instructions = extractWorkOSLoginInstructions(login.stdout, login.stderr);
|
|
465
|
+
if (login.status === 0) {
|
|
466
|
+
return {
|
|
467
|
+
required: true,
|
|
468
|
+
ok: true,
|
|
469
|
+
method: "cli",
|
|
470
|
+
statusCommand,
|
|
471
|
+
loginCommand,
|
|
472
|
+
loginAttempted: true,
|
|
473
|
+
authenticated: true,
|
|
474
|
+
status: status.status,
|
|
475
|
+
loginStatus: login.status,
|
|
476
|
+
detail: "WorkOS CLI login completed or was already authenticated",
|
|
477
|
+
...(instructions ? { loginInstructions: instructions } : {}),
|
|
478
|
+
nextActions: [],
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const nextActions = [
|
|
483
|
+
"complete the WorkOS CLI OAuth/device-code login shown in loginInstructions",
|
|
484
|
+
"rerun forge workos prove --real --file workos-seed.yml --json",
|
|
485
|
+
];
|
|
486
|
+
return {
|
|
487
|
+
required: true,
|
|
488
|
+
ok: false,
|
|
489
|
+
method: "cli",
|
|
490
|
+
statusCommand,
|
|
491
|
+
loginCommand,
|
|
492
|
+
loginAttempted: true,
|
|
493
|
+
authenticated,
|
|
494
|
+
...(email ? { email } : {}),
|
|
495
|
+
...(userId ? { userId } : {}),
|
|
496
|
+
tokenExpired,
|
|
497
|
+
hasRefreshToken,
|
|
498
|
+
...(activeEnvironment ? { activeEnvironment } : {}),
|
|
499
|
+
status: status.status,
|
|
500
|
+
loginStatus: login.status,
|
|
501
|
+
detail: instructions?.url || instructions?.code
|
|
502
|
+
? "WorkOS CLI login is required; open the URL and enter the code from loginInstructions, then rerun the command"
|
|
503
|
+
: "WorkOS CLI login is required; run WORKOS_MODE=agent npx --yes workos@latest auth login --json and rerun the Forge command",
|
|
504
|
+
...(instructions ? { loginInstructions: instructions } : {}),
|
|
505
|
+
nextActions,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function collectWorkOSRealEnvChecks(workspaceRoot: string, cliAuth?: WorkOSCliAuthSummary): WorkOSCheck[] {
|
|
510
|
+
const env = readRealEnv(workspaceRoot);
|
|
511
|
+
const authMode = env.FORGE_AUTH_MODE;
|
|
512
|
+
const clientId = env.WORKOS_CLIENT_ID || env.VITE_WORKOS_CLIENT_ID;
|
|
513
|
+
const required = [
|
|
514
|
+
["WORKOS_CLIENT_ID", "WorkOS client ID is required for AuthKit and JWKS discovery"],
|
|
515
|
+
["WORKOS_COOKIE_PASSWORD", "cookie password is required for AuthKit session signing"],
|
|
516
|
+
["FORGE_AUTH_ISSUER", "Forge OIDC issuer must be https://api.workos.com"],
|
|
517
|
+
["FORGE_AUTH_AUDIENCE", "Forge OIDC audience must match the WorkOS client/application audience"],
|
|
518
|
+
["FORGE_AUTH_JWKS_URI", `Forge JWKS URI must be configured, usually ${workosJwksUri(clientId)}`],
|
|
519
|
+
] as const;
|
|
520
|
+
return [
|
|
521
|
+
{
|
|
522
|
+
name: "real-env-auth-mode",
|
|
523
|
+
ok: authMode === "oidc" || authMode === "jwt",
|
|
524
|
+
detail: authMode === "oidc" || authMode === "jwt"
|
|
525
|
+
? `FORGE_AUTH_MODE=${authMode} is production-capable`
|
|
526
|
+
: "FORGE_AUTH_MODE must be oidc or jwt before running hosted WorkOS proof",
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
name: "real-env-workos_api_key-or-cli-auth",
|
|
530
|
+
ok: hasValue(env, "WORKOS_API_KEY") || cliAuth?.ok === true,
|
|
531
|
+
detail: hasValue(env, "WORKOS_API_KEY")
|
|
532
|
+
? "WORKOS_API_KEY is present"
|
|
533
|
+
: cliAuth?.ok
|
|
534
|
+
? "WORKOS_API_KEY is missing, but WorkOS CLI authentication is available for no-dashboard hosted setup"
|
|
535
|
+
: "WORKOS_API_KEY is missing and WorkOS CLI authentication is not complete; run WorkOS CLI login",
|
|
536
|
+
},
|
|
537
|
+
...required.map(([name, detail]) => ({
|
|
538
|
+
name: `real-env-${name.toLowerCase()}`,
|
|
539
|
+
ok: hasValue(env, name),
|
|
540
|
+
detail: hasValue(env, name) ? `${name} is present` : `${name} is missing: ${detail}`,
|
|
541
|
+
})),
|
|
542
|
+
];
|
|
543
|
+
}
|
|
544
|
+
|
|
332
545
|
export function collectPolicyPermissions(workspaceRoot: string): string[] {
|
|
333
546
|
const registry = readJson(workspaceRoot, `${GENERATED_DIR}/policyRegistry.json`) as {
|
|
334
547
|
policies?: Array<{ permissions?: string[] }>;
|
|
@@ -391,9 +604,945 @@ export function missingValues(expected: string[], actual: string[]): string[] {
|
|
|
391
604
|
return expected.filter((value) => !actualSet.has(value));
|
|
392
605
|
}
|
|
393
606
|
|
|
607
|
+
export interface WorkOSFgaResource {
|
|
608
|
+
externalId: string;
|
|
609
|
+
type: string;
|
|
610
|
+
tenant: string;
|
|
611
|
+
name: string;
|
|
612
|
+
parentExternalId?: string;
|
|
613
|
+
parentType?: string;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export interface WorkOSFgaProofScenario {
|
|
617
|
+
name: string;
|
|
618
|
+
expected: "allow" | "deny";
|
|
619
|
+
permission: string;
|
|
620
|
+
resourceExternalId: string;
|
|
621
|
+
resourceTypeSlug: string;
|
|
622
|
+
organization: string;
|
|
623
|
+
reason: string;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export interface WorkOSFgaManifest {
|
|
627
|
+
schemaVersion: "0.1.0";
|
|
628
|
+
provider: "workos";
|
|
629
|
+
kind: "fga-manifest";
|
|
630
|
+
seedFile: string;
|
|
631
|
+
seedHash?: string;
|
|
632
|
+
manifestHash: string;
|
|
633
|
+
permissions: string[];
|
|
634
|
+
roles: string[];
|
|
635
|
+
resourceTypes: string[];
|
|
636
|
+
organizations: string[];
|
|
637
|
+
resources: WorkOSFgaResource[];
|
|
638
|
+
proofScenarios: WorkOSFgaProofScenario[];
|
|
639
|
+
diagnostics: string[];
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export interface WorkOSFgaStateSummary {
|
|
643
|
+
exists: boolean;
|
|
644
|
+
valid: boolean;
|
|
645
|
+
path: string;
|
|
646
|
+
matchesManifestHash: boolean | null;
|
|
647
|
+
manifestHash?: string;
|
|
648
|
+
currentManifestHash?: string;
|
|
649
|
+
syncedAt?: string;
|
|
650
|
+
provedAt?: string;
|
|
651
|
+
mode?: "local" | "real";
|
|
652
|
+
sdkOk?: boolean;
|
|
653
|
+
diagnostics: string[];
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export interface WorkOSFgaHostedSetup {
|
|
657
|
+
requiredResourceTypes: string[];
|
|
658
|
+
rootResourceType: "organization";
|
|
659
|
+
missingResourceTypes: string[];
|
|
660
|
+
requiredMembershipEnv: string[];
|
|
661
|
+
managedBy: "hosted-workos";
|
|
662
|
+
cliSupport: "resources-and-checks";
|
|
663
|
+
sdkSupport: "resources-and-checks";
|
|
664
|
+
docs: string[];
|
|
665
|
+
nextActions: string[];
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export interface WorkOSFgaResourceTypeSetup {
|
|
669
|
+
slug: string;
|
|
670
|
+
displayName: string;
|
|
671
|
+
hostedAction: "none" | "configure-resource-type";
|
|
672
|
+
requiredBeforeRealSync: boolean;
|
|
673
|
+
permissions: string[];
|
|
674
|
+
roles: string[];
|
|
675
|
+
parentTypes: string[];
|
|
676
|
+
childTypes: string[];
|
|
677
|
+
exampleExternalIds: string[];
|
|
678
|
+
proofScenarios: string[];
|
|
679
|
+
notes: string[];
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export interface WorkOSFgaSetupGuide {
|
|
683
|
+
resourceTypes: WorkOSFgaResourceTypeSetup[];
|
|
684
|
+
markdown: string;
|
|
685
|
+
docs: string[];
|
|
686
|
+
unsupportedAutomation: string[];
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function slugifyExternalIdPart(value: string): string {
|
|
690
|
+
return value
|
|
691
|
+
.trim()
|
|
692
|
+
.toLowerCase()
|
|
693
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
694
|
+
.replace(/^-+|-+$/g, "") || "demo";
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function preferredParentType(resourceType: string, resourceTypes: string[]): string | undefined {
|
|
698
|
+
const has = (value: string) => resourceTypes.includes(value);
|
|
699
|
+
if (resourceType === "organization") return undefined;
|
|
700
|
+
if (["access_request", "accessRequest", "evidence_document", "evidenceDocument"].includes(resourceType)) {
|
|
701
|
+
if (has("vendor")) return "vendor";
|
|
702
|
+
}
|
|
703
|
+
if (["task", "taskGroup"].includes(resourceType)) {
|
|
704
|
+
if (has("team")) return "team";
|
|
705
|
+
if (has("project")) return "project";
|
|
706
|
+
}
|
|
707
|
+
if (resourceType === "team" && has("project")) return "project";
|
|
708
|
+
return has("organization") ? "organization" : undefined;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function stableJson(value: unknown): string {
|
|
712
|
+
if (Array.isArray(value)) {
|
|
713
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
714
|
+
}
|
|
715
|
+
if (value && typeof value === "object") {
|
|
716
|
+
return `{${Object.entries(value as Record<string, unknown>)
|
|
717
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
718
|
+
.map(([key, child]) => `${JSON.stringify(key)}:${stableJson(child)}`)
|
|
719
|
+
.join(",")}}`;
|
|
720
|
+
}
|
|
721
|
+
return JSON.stringify(value);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function hashObject(value: unknown): string {
|
|
725
|
+
return createHash("sha256").update(stableJson(value)).digest("hex");
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
export function collectWorkOSFgaManifest(
|
|
729
|
+
workspaceRoot: string,
|
|
730
|
+
preferredSeedPath = DEFAULT_SEED_FILE,
|
|
731
|
+
): WorkOSFgaManifest {
|
|
732
|
+
const seed = parseSeedFile(workspaceRoot, preferredSeedPath);
|
|
733
|
+
const activePermissions = collectPolicyPermissions(workspaceRoot);
|
|
734
|
+
const expectedResourceTypes = collectExpectedResourceTypes(workspaceRoot);
|
|
735
|
+
const permissions = uniqueSorted([...seed.permissions, ...activePermissions]);
|
|
736
|
+
const resourceTypes = uniqueSorted([...seed.resourceTypes, ...expectedResourceTypes]);
|
|
737
|
+
const organizations = seed.organizations.length > 0 ? seed.organizations : ["Demo Organization"];
|
|
738
|
+
const diagnostics: string[] = [];
|
|
739
|
+
const missingSeedPermissions = missingValues(activePermissions, seed.permissions);
|
|
740
|
+
const missingSeedResources = missingValues(expectedResourceTypes, seed.resourceTypes);
|
|
741
|
+
if (!seed.exists) diagnostics.push(`${preferredSeedPath} is missing; run forge add auth workos or forge workos seed --dry-run first`);
|
|
742
|
+
if (missingSeedPermissions.length > 0) diagnostics.push(`seed missing active policy permission(s): ${missingSeedPermissions.join(", ")}`);
|
|
743
|
+
if (missingSeedResources.length > 0) diagnostics.push(`seed missing app resource type(s): ${missingSeedResources.join(", ")}`);
|
|
744
|
+
if (!resourceTypes.includes("organization")) diagnostics.push("FGA graph should include an organization resource type for tenant roots");
|
|
745
|
+
if (permissions.length === 0) diagnostics.push("no WorkOS permission slugs were discovered");
|
|
746
|
+
if (seed.roles.length === 0) diagnostics.push("no WorkOS roles were discovered");
|
|
747
|
+
|
|
748
|
+
const resources: WorkOSFgaResource[] = [];
|
|
749
|
+
for (const organization of organizations) {
|
|
750
|
+
const orgSlug = slugifyExternalIdPart(organization);
|
|
751
|
+
const orgExternalId = `organization:${orgSlug}`;
|
|
752
|
+
if (resourceTypes.includes("organization")) {
|
|
753
|
+
resources.push({
|
|
754
|
+
externalId: orgExternalId,
|
|
755
|
+
type: "organization",
|
|
756
|
+
tenant: organization,
|
|
757
|
+
name: organization,
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
for (const resourceType of resourceTypes.filter((type) => type !== "organization")) {
|
|
761
|
+
const parentType = preferredParentType(resourceType, resourceTypes);
|
|
762
|
+
const parentExternalId = parentType
|
|
763
|
+
? parentType === "organization"
|
|
764
|
+
? orgExternalId
|
|
765
|
+
: `${parentType}:${orgSlug}:demo`
|
|
766
|
+
: undefined;
|
|
767
|
+
resources.push({
|
|
768
|
+
externalId: `${resourceType}:${orgSlug}:demo`,
|
|
769
|
+
type: resourceType,
|
|
770
|
+
tenant: organization,
|
|
771
|
+
name: `${organization} ${resourceType}`,
|
|
772
|
+
...(parentType ? { parentType } : {}),
|
|
773
|
+
...(parentExternalId ? { parentExternalId } : {}),
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const firstOrg = organizations[0] ?? "Demo Organization";
|
|
779
|
+
const secondOrg = organizations[1] ?? `${firstOrg} Other`;
|
|
780
|
+
const firstResource = resources.find((resource) => resource.type !== "organization") ?? resources[0];
|
|
781
|
+
const firstPermission = permissions[0] ?? "app:read";
|
|
782
|
+
const proofScenarios: WorkOSFgaProofScenario[] = firstResource
|
|
783
|
+
? [
|
|
784
|
+
{
|
|
785
|
+
name: "allowed-same-tenant",
|
|
786
|
+
expected: "allow",
|
|
787
|
+
permission: firstPermission,
|
|
788
|
+
resourceExternalId: firstResource.externalId,
|
|
789
|
+
resourceTypeSlug: firstResource.type,
|
|
790
|
+
organization: firstOrg,
|
|
791
|
+
reason: "membership, permission, and resource tenant match",
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
name: "cross-tenant-read-denied",
|
|
795
|
+
expected: "deny",
|
|
796
|
+
permission: firstPermission,
|
|
797
|
+
resourceExternalId: firstResource.externalId,
|
|
798
|
+
resourceTypeSlug: firstResource.type,
|
|
799
|
+
organization: secondOrg,
|
|
800
|
+
reason: "resource belongs to a different organization tenant",
|
|
801
|
+
},
|
|
802
|
+
]
|
|
803
|
+
: [];
|
|
804
|
+
|
|
805
|
+
const hashInput = {
|
|
806
|
+
seedFile: seed.path,
|
|
807
|
+
seedHash: seed.exists ? hashSeedFile(workspaceRoot, seed.path) : undefined,
|
|
808
|
+
permissions,
|
|
809
|
+
roles: seed.roles,
|
|
810
|
+
resourceTypes,
|
|
811
|
+
organizations,
|
|
812
|
+
resources,
|
|
813
|
+
proofScenarios,
|
|
814
|
+
};
|
|
815
|
+
return {
|
|
816
|
+
schemaVersion: "0.1.0",
|
|
817
|
+
provider: "workos",
|
|
818
|
+
kind: "fga-manifest",
|
|
819
|
+
seedFile: seed.path,
|
|
820
|
+
...(seed.exists ? { seedHash: hashSeedFile(workspaceRoot, seed.path) } : {}),
|
|
821
|
+
manifestHash: hashObject(hashInput),
|
|
822
|
+
permissions,
|
|
823
|
+
roles: seed.roles,
|
|
824
|
+
resourceTypes,
|
|
825
|
+
organizations,
|
|
826
|
+
resources,
|
|
827
|
+
proofScenarios,
|
|
828
|
+
diagnostics,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
export function readWorkOSFgaState(
|
|
833
|
+
workspaceRoot: string,
|
|
834
|
+
manifest: WorkOSFgaManifest,
|
|
835
|
+
): WorkOSFgaStateSummary {
|
|
836
|
+
const path = WORKOS_FGA_STATE_FILE;
|
|
837
|
+
const absolute = join(workspaceRoot, path);
|
|
838
|
+
if (!existsSync(absolute)) {
|
|
839
|
+
return {
|
|
840
|
+
exists: false,
|
|
841
|
+
valid: false,
|
|
842
|
+
path,
|
|
843
|
+
matchesManifestHash: null,
|
|
844
|
+
currentManifestHash: manifest.manifestHash,
|
|
845
|
+
diagnostics: [`${path} is missing; run forge workos fga sync --json`],
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
try {
|
|
849
|
+
const parsed = JSON.parse(readFileSync(absolute, "utf8")) as {
|
|
850
|
+
manifestHash?: unknown;
|
|
851
|
+
syncedAt?: unknown;
|
|
852
|
+
provedAt?: unknown;
|
|
853
|
+
mode?: unknown;
|
|
854
|
+
sdkOk?: unknown;
|
|
855
|
+
};
|
|
856
|
+
const diagnostics: string[] = [];
|
|
857
|
+
const manifestHash = typeof parsed.manifestHash === "string" ? parsed.manifestHash : undefined;
|
|
858
|
+
const mode = parsed.mode === "real" ? "real" : parsed.mode === "local" ? "local" : undefined;
|
|
859
|
+
const sdkOk = typeof parsed.sdkOk === "boolean" ? parsed.sdkOk : undefined;
|
|
860
|
+
if (!manifestHash) diagnostics.push("FGA state is missing manifestHash");
|
|
861
|
+
if (typeof parsed.syncedAt !== "string") diagnostics.push("FGA state is missing syncedAt");
|
|
862
|
+
if (!mode) diagnostics.push("FGA state is missing mode");
|
|
863
|
+
if (mode === "real" && sdkOk !== true) diagnostics.push("real FGA state is missing sdkOk:true from WorkOS Authorization API sync/proof");
|
|
864
|
+
return {
|
|
865
|
+
exists: true,
|
|
866
|
+
valid: diagnostics.length === 0,
|
|
867
|
+
path,
|
|
868
|
+
matchesManifestHash: manifestHash ? manifestHash === manifest.manifestHash : null,
|
|
869
|
+
...(manifestHash ? { manifestHash } : {}),
|
|
870
|
+
currentManifestHash: manifest.manifestHash,
|
|
871
|
+
...(typeof parsed.syncedAt === "string" ? { syncedAt: parsed.syncedAt } : {}),
|
|
872
|
+
...(typeof parsed.provedAt === "string" ? { provedAt: parsed.provedAt } : {}),
|
|
873
|
+
...(mode ? { mode } : {}),
|
|
874
|
+
...(sdkOk !== undefined ? { sdkOk } : {}),
|
|
875
|
+
diagnostics,
|
|
876
|
+
};
|
|
877
|
+
} catch (error) {
|
|
878
|
+
return {
|
|
879
|
+
exists: true,
|
|
880
|
+
valid: false,
|
|
881
|
+
path,
|
|
882
|
+
matchesManifestHash: null,
|
|
883
|
+
currentManifestHash: manifest.manifestHash,
|
|
884
|
+
diagnostics: [`failed to parse ${path}: ${error instanceof Error ? error.message : String(error)}`],
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function writeWorkOSFgaState(input: {
|
|
890
|
+
workspaceRoot: string;
|
|
891
|
+
manifest: WorkOSFgaManifest;
|
|
892
|
+
mode: "local" | "real";
|
|
893
|
+
proved: boolean;
|
|
894
|
+
sdkOk?: boolean;
|
|
895
|
+
sdk?: unknown;
|
|
896
|
+
}): string {
|
|
897
|
+
const now = new Date().toISOString();
|
|
898
|
+
const payload = {
|
|
899
|
+
schemaVersion: "0.1.0",
|
|
900
|
+
provider: "workos",
|
|
901
|
+
kind: "fga-state",
|
|
902
|
+
mode: input.mode,
|
|
903
|
+
seedFile: input.manifest.seedFile,
|
|
904
|
+
seedHash: input.manifest.seedHash,
|
|
905
|
+
manifestHash: input.manifest.manifestHash,
|
|
906
|
+
syncedAt: now,
|
|
907
|
+
...(input.proved ? { provedAt: now } : {}),
|
|
908
|
+
...(input.sdkOk !== undefined ? { sdkOk: input.sdkOk } : {}),
|
|
909
|
+
...(input.sdk ? { sdk: input.sdk } : {}),
|
|
910
|
+
permissions: input.manifest.permissions,
|
|
911
|
+
roles: input.manifest.roles,
|
|
912
|
+
resourceTypes: input.manifest.resourceTypes,
|
|
913
|
+
organizations: input.manifest.organizations,
|
|
914
|
+
resources: input.manifest.resources,
|
|
915
|
+
proofScenarios: input.manifest.proofScenarios,
|
|
916
|
+
};
|
|
917
|
+
writeFileSync(join(input.workspaceRoot, WORKOS_FGA_STATE_FILE), `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
918
|
+
return WORKOS_FGA_STATE_FILE;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function hostedFgaResourceTypes(manifest: WorkOSFgaManifest): string[] {
|
|
922
|
+
return manifest.resourceTypes.filter((resourceType) => resourceType !== "organization");
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function fgaMembershipEnvKey(organization: string): string {
|
|
926
|
+
const suffix = slugifyExternalIdPart(organization).replace(/-/g, "_").toUpperCase();
|
|
927
|
+
return `WORKOS_FGA_MEMBERSHIP_${suffix}`;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function extractMissingWorkOSFgaResourceTypes(data: unknown, manifest?: WorkOSFgaManifest): string[] {
|
|
931
|
+
const missing = new Set<string>();
|
|
932
|
+
const add = (value: unknown) => {
|
|
933
|
+
if (typeof value === "string" && value && value !== "organization") {
|
|
934
|
+
missing.add(value);
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
if (data && typeof data === "object") {
|
|
938
|
+
const errors = (data as { errors?: unknown }).errors;
|
|
939
|
+
if (Array.isArray(errors)) {
|
|
940
|
+
for (const error of errors) {
|
|
941
|
+
if (!error || typeof error !== "object") continue;
|
|
942
|
+
const record = error as Record<string, unknown>;
|
|
943
|
+
const message = typeof record.message === "string" ? record.message : "";
|
|
944
|
+
const status = String(record.status ?? "");
|
|
945
|
+
if (message.includes("AuthorizationResourceType not found") || status === "404") {
|
|
946
|
+
add(record.resourceTypeSlug);
|
|
947
|
+
}
|
|
948
|
+
const match = /AuthorizationResourceType not found:\s*['"]([^'"]+)['"]/.exec(message);
|
|
949
|
+
add(match?.[1]);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
const text = JSON.stringify(data) ?? "";
|
|
954
|
+
const missingTypePattern = /AuthorizationResourceType not found:\s*['"]([^'"]+)['"]/g;
|
|
955
|
+
let match: RegExpExecArray | null;
|
|
956
|
+
while ((match = missingTypePattern.exec(text))) {
|
|
957
|
+
add(match[1]);
|
|
958
|
+
}
|
|
959
|
+
if (missing.size === 0 && text.includes("AuthorizationResourceType not found") && manifest) {
|
|
960
|
+
for (const resourceType of hostedFgaResourceTypes(manifest)) {
|
|
961
|
+
missing.add(resourceType);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
return uniqueSorted(missing);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function workOSFgaHostedSetup(manifest: WorkOSFgaManifest, sdkData?: unknown): WorkOSFgaHostedSetup {
|
|
968
|
+
const requiredResourceTypes = hostedFgaResourceTypes(manifest);
|
|
969
|
+
const missingResourceTypes = extractMissingWorkOSFgaResourceTypes(sdkData, manifest);
|
|
970
|
+
const resourceList = (missingResourceTypes.length > 0 ? missingResourceTypes : requiredResourceTypes).join(", ") || "none";
|
|
971
|
+
const requiredMembershipEnv = manifest.organizations.map(fgaMembershipEnvKey);
|
|
972
|
+
return {
|
|
973
|
+
requiredResourceTypes,
|
|
974
|
+
rootResourceType: "organization",
|
|
975
|
+
missingResourceTypes,
|
|
976
|
+
requiredMembershipEnv,
|
|
977
|
+
managedBy: "hosted-workos",
|
|
978
|
+
cliSupport: "resources-and-checks",
|
|
979
|
+
sdkSupport: "resources-and-checks",
|
|
980
|
+
docs: [
|
|
981
|
+
"https://workos.com/docs/fga/resource-types",
|
|
982
|
+
"https://workos.com/docs/fga/resources",
|
|
983
|
+
"https://workos.com/docs/fga/access-checks",
|
|
984
|
+
],
|
|
985
|
+
nextActions: [
|
|
986
|
+
missingResourceTypes.length > 0
|
|
987
|
+
? `configure missing WorkOS FGA resource type(s): ${resourceList}`
|
|
988
|
+
: `confirm WorkOS FGA resource type(s) exist: ${resourceList}`,
|
|
989
|
+
"treat organization as the WorkOS tenant root; ForgeOS does not create it as an authorization resource",
|
|
990
|
+
`set WorkOS FGA membership env for real access checks: WORKOS_FGA_MEMBERSHIPS_JSON or ${requiredMembershipEnv.join(", ") || "WORKOS_FGA_MEMBERSHIP_<ORG>"}`,
|
|
991
|
+
"rerun forge workos fga sync --real --file workos-seed.yml --json",
|
|
992
|
+
"rerun forge workos fga prove --real --file workos-seed.yml --json",
|
|
993
|
+
"rerun forge deploy check --production --json",
|
|
994
|
+
],
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function displayResourceType(slug: string): string {
|
|
999
|
+
return slug
|
|
1000
|
+
.replace(/[_-]+/g, " ")
|
|
1001
|
+
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function permissionResourcePart(permission: string): string {
|
|
1005
|
+
return permission.split(":")[0] ?? permission;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function permissionMatchesResourceType(permission: string, resourceType: string): boolean {
|
|
1009
|
+
const part = permissionResourcePart(permission);
|
|
1010
|
+
const normalizedPart = singularResourceName(part.replace(/[-_]/g, ""));
|
|
1011
|
+
const normalizedType = singularResourceName(resourceType.replace(/[-_]/g, ""));
|
|
1012
|
+
return part === resourceType ||
|
|
1013
|
+
singularResourceName(part) === resourceType ||
|
|
1014
|
+
normalizedPart === normalizedType ||
|
|
1015
|
+
normalizedType.startsWith(normalizedPart) ||
|
|
1016
|
+
normalizedPart === `${normalizedType}request`;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function permissionsForResourceType(manifest: WorkOSFgaManifest, resourceType: string): string[] {
|
|
1020
|
+
if (resourceType === "organization") {
|
|
1021
|
+
return manifest.permissions.filter((permission) => permissionResourcePart(permission) === "organization");
|
|
1022
|
+
}
|
|
1023
|
+
return manifest.permissions.filter((permission) => permissionMatchesResourceType(permission, resourceType));
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function roleLooksRelevantToResourceType(role: string, resourceType: string): boolean {
|
|
1027
|
+
const normalizedRole = role.replace(/[-_]/g, "");
|
|
1028
|
+
const normalizedType = resourceType.replace(/[-_]/g, "");
|
|
1029
|
+
return normalizedRole.includes(normalizedType) ||
|
|
1030
|
+
normalizedRole.includes(singularResourceName(normalizedType)) ||
|
|
1031
|
+
["owner", "admin", "manager", "member", "auditor", "reviewer", "requester", "security"].some((shared) => normalizedRole.includes(shared));
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function rolesForResourceType(manifest: WorkOSFgaManifest, resourceType: string): string[] {
|
|
1035
|
+
const relevant = manifest.roles.filter((role) => roleLooksRelevantToResourceType(role, resourceType));
|
|
1036
|
+
return relevant.length > 0 ? relevant : manifest.roles;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function workOSFgaSetupGuide(manifest: WorkOSFgaManifest, hostedSetup: WorkOSFgaHostedSetup): WorkOSFgaSetupGuide {
|
|
1040
|
+
const resourcesByType = new Map<string, WorkOSFgaResource[]>();
|
|
1041
|
+
for (const resource of manifest.resources) {
|
|
1042
|
+
resourcesByType.set(resource.type, [...resourcesByType.get(resource.type) ?? [], resource]);
|
|
1043
|
+
}
|
|
1044
|
+
const resourceTypes = manifest.resourceTypes.map((slug): WorkOSFgaResourceTypeSetup => {
|
|
1045
|
+
const resources = resourcesByType.get(slug) ?? [];
|
|
1046
|
+
const parentTypes = uniqueSorted(resources.map((resource) => resource.parentType ?? "").filter(Boolean));
|
|
1047
|
+
const childTypes = uniqueSorted(manifest.resources
|
|
1048
|
+
.filter((resource) => resource.parentType === slug)
|
|
1049
|
+
.map((resource) => resource.type));
|
|
1050
|
+
const proofScenarios = manifest.proofScenarios
|
|
1051
|
+
.filter((scenario) => scenario.resourceTypeSlug === slug)
|
|
1052
|
+
.map((scenario) => `${scenario.name}:${scenario.expected}`);
|
|
1053
|
+
const permissions = permissionsForResourceType(manifest, slug);
|
|
1054
|
+
const roles = rolesForResourceType(manifest, slug);
|
|
1055
|
+
const isRoot = slug === hostedSetup.rootResourceType;
|
|
1056
|
+
return {
|
|
1057
|
+
slug,
|
|
1058
|
+
displayName: displayResourceType(slug),
|
|
1059
|
+
hostedAction: isRoot ? "none" : "configure-resource-type",
|
|
1060
|
+
requiredBeforeRealSync: !isRoot,
|
|
1061
|
+
permissions,
|
|
1062
|
+
roles,
|
|
1063
|
+
parentTypes,
|
|
1064
|
+
childTypes,
|
|
1065
|
+
exampleExternalIds: resources.map((resource) => resource.externalId).slice(0, 4),
|
|
1066
|
+
proofScenarios,
|
|
1067
|
+
notes: isRoot
|
|
1068
|
+
? [
|
|
1069
|
+
"Treat WorkOS organization as the tenant root.",
|
|
1070
|
+
"ForgeOS keeps organization in the graph for parent/tenant reasoning, but real sync does not create it as an authorization resource.",
|
|
1071
|
+
]
|
|
1072
|
+
: [
|
|
1073
|
+
"Create/configure this resource type in hosted WorkOS before real sync.",
|
|
1074
|
+
parentTypes.length > 0
|
|
1075
|
+
? `Expected parent type(s): ${parentTypes.join(", ")}.`
|
|
1076
|
+
: "No parent type inferred from the app graph.",
|
|
1077
|
+
],
|
|
1078
|
+
};
|
|
1079
|
+
});
|
|
1080
|
+
const markdown = [
|
|
1081
|
+
"# WorkOS FGA Setup",
|
|
1082
|
+
"",
|
|
1083
|
+
"ForgeOS derived this resource graph from the app contract, policies, and workos-seed.yml.",
|
|
1084
|
+
"WorkOS resource type configuration is hosted WorkOS configuration. ForgeOS uses the WorkOS CLI/API or SDK to sync resources and prove authorization checks after those resource types exist.",
|
|
1085
|
+
"",
|
|
1086
|
+
"## Resource Types",
|
|
1087
|
+
"",
|
|
1088
|
+
...resourceTypes.flatMap((resourceType) => [
|
|
1089
|
+
`### ${resourceType.slug}`,
|
|
1090
|
+
"",
|
|
1091
|
+
`- Display name: ${resourceType.displayName}`,
|
|
1092
|
+
`- Hosted action: ${resourceType.hostedAction}`,
|
|
1093
|
+
`- Required before real sync: ${resourceType.requiredBeforeRealSync ? "yes" : "no"}`,
|
|
1094
|
+
`- Parent types: ${resourceType.parentTypes.join(", ") || "none"}`,
|
|
1095
|
+
`- Child types: ${resourceType.childTypes.join(", ") || "none"}`,
|
|
1096
|
+
`- Permissions to attach/model: ${resourceType.permissions.join(", ") || "none inferred"}`,
|
|
1097
|
+
`- Roles to review for this type: ${resourceType.roles.join(", ") || "none inferred"}`,
|
|
1098
|
+
`- Example external IDs: ${resourceType.exampleExternalIds.join(", ") || "none"}`,
|
|
1099
|
+
`- Proof scenarios: ${resourceType.proofScenarios.join(", ") || "none"}`,
|
|
1100
|
+
...resourceType.notes.map((note) => `- Note: ${note}`),
|
|
1101
|
+
"",
|
|
1102
|
+
]),
|
|
1103
|
+
"## Permission And Role Coverage",
|
|
1104
|
+
"",
|
|
1105
|
+
`- Permissions discovered: ${manifest.permissions.join(", ") || "none"}`,
|
|
1106
|
+
`- Roles discovered: ${manifest.roles.join(", ") || "none"}`,
|
|
1107
|
+
"- In hosted WorkOS, ensure each permission is scoped to the intended resource type and each role includes the permissions needed by your Forge policies.",
|
|
1108
|
+
"- ForgeOS will not claim production readiness until real Authorization API checks pass for the generated proof scenarios.",
|
|
1109
|
+
"",
|
|
1110
|
+
"## Required Membership Environment",
|
|
1111
|
+
"",
|
|
1112
|
+
"- WORKOS_FGA_MEMBERSHIPS_JSON: JSON object mapping organization name to organizationMembershipId",
|
|
1113
|
+
...hostedSetup.requiredMembershipEnv.map((env) => `- ${env}: organizationMembershipId for that organization`),
|
|
1114
|
+
"",
|
|
1115
|
+
"## Commands",
|
|
1116
|
+
"",
|
|
1117
|
+
"```bash",
|
|
1118
|
+
"forge workos fga plan --file workos-seed.yml --write --json",
|
|
1119
|
+
"forge workos fga sync --real --file workos-seed.yml --json",
|
|
1120
|
+
"forge workos fga prove --real --file workos-seed.yml --json",
|
|
1121
|
+
"forge deploy check --production --json",
|
|
1122
|
+
"```",
|
|
1123
|
+
"",
|
|
1124
|
+
].join("\n");
|
|
1125
|
+
return {
|
|
1126
|
+
resourceTypes,
|
|
1127
|
+
markdown,
|
|
1128
|
+
docs: hostedSetup.docs,
|
|
1129
|
+
unsupportedAutomation: [
|
|
1130
|
+
"ForgeOS does not invent WorkOS CLI/API calls for resource type creation.",
|
|
1131
|
+
"ForgeOS can create/read FGA resources and run authorization checks through the WorkOS CLI/API or SDK after resource types exist.",
|
|
1132
|
+
],
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function resolveWorkOSFgaSetupGuidePath(options: WorkOSCommandOptions): string | undefined {
|
|
1137
|
+
if (!options.write && !options.writePath) {
|
|
1138
|
+
return undefined;
|
|
1139
|
+
}
|
|
1140
|
+
const candidate = options.writePath?.trim();
|
|
1141
|
+
return candidate && candidate !== "true" && !candidate.startsWith("--") ? candidate : WORKOS_FGA_SETUP_GUIDE_FILE;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function writeWorkOSFgaSetupGuide(
|
|
1145
|
+
workspaceRoot: string,
|
|
1146
|
+
guide: WorkOSFgaSetupGuide,
|
|
1147
|
+
preferredPath = WORKOS_FGA_SETUP_GUIDE_FILE,
|
|
1148
|
+
): string {
|
|
1149
|
+
const relativePath = preferredPath.startsWith("/") ? preferredPath.slice(1) : preferredPath;
|
|
1150
|
+
const absolutePath = join(workspaceRoot, relativePath);
|
|
1151
|
+
mkdirSync(dirname(absolutePath), { recursive: true });
|
|
1152
|
+
writeFileSync(absolutePath, guide.markdown, "utf8");
|
|
1153
|
+
return relativePath;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function fgaData(input: {
|
|
1157
|
+
action: WorkOSFgaAction;
|
|
1158
|
+
manifest: WorkOSFgaManifest;
|
|
1159
|
+
state: WorkOSFgaStateSummary;
|
|
1160
|
+
real?: boolean;
|
|
1161
|
+
cliAuth?: WorkOSCliAuthSummary;
|
|
1162
|
+
workosSdk?: unknown;
|
|
1163
|
+
stateFile?: string;
|
|
1164
|
+
nextCommand?: string;
|
|
1165
|
+
nextActions?: string[];
|
|
1166
|
+
setupGuidePath?: string;
|
|
1167
|
+
}): Record<string, unknown> {
|
|
1168
|
+
const hostedSetup = workOSFgaHostedSetup(input.manifest, input.workosSdk);
|
|
1169
|
+
const setupGuide = workOSFgaSetupGuide(input.manifest, hostedSetup);
|
|
1170
|
+
return {
|
|
1171
|
+
action: input.action,
|
|
1172
|
+
real: input.real ?? false,
|
|
1173
|
+
manifest: input.manifest,
|
|
1174
|
+
state: input.state,
|
|
1175
|
+
hostedSetup,
|
|
1176
|
+
resourceTypeSetup: setupGuide.resourceTypes,
|
|
1177
|
+
setupGuide,
|
|
1178
|
+
...(input.cliAuth ? { cliAuth: input.cliAuth } : {}),
|
|
1179
|
+
...(input.workosSdk ? { workosSdk: input.workosSdk } : {}),
|
|
1180
|
+
...(input.stateFile ? { stateFile: input.stateFile } : {}),
|
|
1181
|
+
...(input.setupGuidePath ? { setupGuidePath: input.setupGuidePath } : {}),
|
|
1182
|
+
nextCommand: input.nextCommand,
|
|
1183
|
+
nextActions: input.nextActions ?? hostedSetup.nextActions,
|
|
1184
|
+
notes: [
|
|
1185
|
+
"ForgeOS derives resource graph and proof scenarios from app contract, policies, and workos-seed.yml.",
|
|
1186
|
+
"WorkOS FGA resource types are hosted WorkOS configuration; ForgeOS syncs/proves resources and gates production deploys through .workos-fga-state.json.",
|
|
1187
|
+
],
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function runWorkOSFgaSdk(
|
|
1192
|
+
options: WorkOSCommandOptions,
|
|
1193
|
+
manifest: WorkOSFgaManifest,
|
|
1194
|
+
action: "sync" | "prove",
|
|
1195
|
+
): { ok: boolean; command: string[]; data: Record<string, unknown>; status: number | null; stdout?: string; stderr?: string } {
|
|
1196
|
+
const script = String.raw`
|
|
1197
|
+
const { spawnSync } = await import("node:child_process");
|
|
1198
|
+
const payload = JSON.parse(process.env.FORGE_WORKOS_FGA_PAYLOAD || "{}");
|
|
1199
|
+
const out = { ok: true, action: payload.action, resources: [], checks: [], skipped: [], errors: [] };
|
|
1200
|
+
function message(error) {
|
|
1201
|
+
return error && typeof error === "object" && "message" in error ? String(error.message) : String(error);
|
|
1202
|
+
}
|
|
1203
|
+
function status(error) {
|
|
1204
|
+
return error && typeof error === "object" && "status" in error ? error.status : error && typeof error === "object" && "statusCode" in error ? error.statusCode : undefined;
|
|
1205
|
+
}
|
|
1206
|
+
async function listAll(page) {
|
|
1207
|
+
if (!page) return [];
|
|
1208
|
+
if (Array.isArray(page.data)) return page.data;
|
|
1209
|
+
if (Symbol.asyncIterator in Object(page)) {
|
|
1210
|
+
const values = [];
|
|
1211
|
+
for await (const item of page) values.push(item);
|
|
1212
|
+
return values;
|
|
1213
|
+
}
|
|
1214
|
+
return [];
|
|
1215
|
+
}
|
|
1216
|
+
function slugify(value) {
|
|
1217
|
+
return String(value || "")
|
|
1218
|
+
.trim()
|
|
1219
|
+
.toLowerCase()
|
|
1220
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1221
|
+
.replace(/^-+|-+$/g, "") || "demo";
|
|
1222
|
+
}
|
|
1223
|
+
function membershipEnvKey(organization) {
|
|
1224
|
+
return "WORKOS_FGA_MEMBERSHIP_" + slugify(organization).replace(/-/g, "_").toUpperCase();
|
|
1225
|
+
}
|
|
1226
|
+
function readMembershipMap(organizations) {
|
|
1227
|
+
const memberships = new Map();
|
|
1228
|
+
for (const name of ["WORKOS_FGA_MEMBERSHIPS_JSON", "WORKOS_FGA_TEST_MEMBERSHIPS"]) {
|
|
1229
|
+
const raw = process.env[name];
|
|
1230
|
+
if (!raw) continue;
|
|
1231
|
+
try {
|
|
1232
|
+
const parsed = JSON.parse(raw);
|
|
1233
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1234
|
+
for (const [organization, membershipId] of Object.entries(parsed)) {
|
|
1235
|
+
if (typeof membershipId === "string" && membershipId.trim()) {
|
|
1236
|
+
memberships.set(organization, membershipId.trim());
|
|
1237
|
+
memberships.set(slugify(organization), membershipId.trim());
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
} catch (error) {
|
|
1242
|
+
out.ok = false;
|
|
1243
|
+
out.errors.push({ operation: "membership.env", env: name, message: "failed to parse JSON membership map: " + message(error) });
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
for (const organization of organizations || []) {
|
|
1247
|
+
const envKey = membershipEnvKey(organization);
|
|
1248
|
+
const membershipId = process.env[envKey];
|
|
1249
|
+
if (membershipId && membershipId.trim()) {
|
|
1250
|
+
memberships.set(organization, membershipId.trim());
|
|
1251
|
+
memberships.set(slugify(organization), membershipId.trim());
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
const legacy = process.env.WORKOS_FGA_TEST_MEMBERSHIP_ID || process.env.WORKOS_FGA_ORGANIZATION_MEMBERSHIP_ID;
|
|
1255
|
+
if (legacy && legacy.trim() && organizations && organizations.length === 1) {
|
|
1256
|
+
memberships.set(organizations[0], legacy.trim());
|
|
1257
|
+
memberships.set(slugify(organizations[0]), legacy.trim());
|
|
1258
|
+
}
|
|
1259
|
+
return memberships;
|
|
1260
|
+
}
|
|
1261
|
+
function parseCliJson(text) {
|
|
1262
|
+
try {
|
|
1263
|
+
const parsed = JSON.parse(text || "{}");
|
|
1264
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
1265
|
+
} catch (error) {
|
|
1266
|
+
return { parseError: message(error), raw: text };
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
function cliApi(path, options = {}) {
|
|
1270
|
+
const args = ["--yes", "workos@latest", "api", path, "--method", options.method || "GET", "--json"];
|
|
1271
|
+
if (options.data) args.push("--data", JSON.stringify(options.data));
|
|
1272
|
+
if (options.yes) args.push("--yes");
|
|
1273
|
+
const child = spawnSync("npx", args, {
|
|
1274
|
+
encoding: "utf8",
|
|
1275
|
+
env: { ...process.env, WORKOS_MODE: process.env.WORKOS_MODE || "agent" },
|
|
1276
|
+
});
|
|
1277
|
+
const parsed = parseCliJson(child.stdout);
|
|
1278
|
+
return {
|
|
1279
|
+
ok: child.status === 0,
|
|
1280
|
+
status: child.status,
|
|
1281
|
+
data: parsed,
|
|
1282
|
+
stdout: child.stdout,
|
|
1283
|
+
stderr: child.stderr,
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
function dataList(payload) {
|
|
1287
|
+
if (Array.isArray(payload)) return payload;
|
|
1288
|
+
if (Array.isArray(payload?.data)) return payload.data;
|
|
1289
|
+
if (Array.isArray(payload?.list)) return payload.list;
|
|
1290
|
+
return [];
|
|
1291
|
+
}
|
|
1292
|
+
function encodePathPart(value) {
|
|
1293
|
+
return encodeURIComponent(String(value));
|
|
1294
|
+
}
|
|
1295
|
+
async function runWithWorkOSCli() {
|
|
1296
|
+
const orgResponse = cliApi("/organizations", { method: "GET" });
|
|
1297
|
+
if (!orgResponse.ok) {
|
|
1298
|
+
out.ok = false;
|
|
1299
|
+
out.errors.push({ operation: "cli.organizations.list", status: orgResponse.status, message: orgResponse.stderr || JSON.stringify(orgResponse.data) });
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
const orgs = dataList(orgResponse.data);
|
|
1303
|
+
const orgByName = new Map(orgs.map((org) => [org.name, org]));
|
|
1304
|
+
const sortedResources = [...payload.manifest.resources].sort((a, b) => {
|
|
1305
|
+
if (a.type === "organization") return -1;
|
|
1306
|
+
if (b.type === "organization") return 1;
|
|
1307
|
+
if (!a.parentExternalId && b.parentExternalId) return -1;
|
|
1308
|
+
if (a.parentExternalId && !b.parentExternalId) return 1;
|
|
1309
|
+
return a.externalId.localeCompare(b.externalId);
|
|
1310
|
+
});
|
|
1311
|
+
for (const resource of sortedResources) {
|
|
1312
|
+
if (resource.type === "organization") {
|
|
1313
|
+
out.resources.push({ externalId: resource.externalId, resourceTypeSlug: resource.type, organizationName: resource.tenant, status: "root-organization" });
|
|
1314
|
+
continue;
|
|
1315
|
+
}
|
|
1316
|
+
const org = orgByName.get(resource.tenant);
|
|
1317
|
+
if (!org?.id) {
|
|
1318
|
+
out.ok = false;
|
|
1319
|
+
out.errors.push({ operation: "organization.lookup", organization: resource.tenant, message: "organization not found in WorkOS environment" });
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1322
|
+
const getPath = "/authorization/organizations/" + encodePathPart(org.id) + "/resources/" + encodePathPart(resource.type) + "/" + encodePathPart(resource.externalId);
|
|
1323
|
+
const existing = cliApi(getPath, { method: "GET" });
|
|
1324
|
+
if (existing.ok) {
|
|
1325
|
+
out.resources.push({ externalId: resource.externalId, resourceTypeSlug: resource.type, organizationId: org.id, status: "existing", id: existing.data?.id });
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1328
|
+
const created = cliApi("/authorization/resources", {
|
|
1329
|
+
method: "POST",
|
|
1330
|
+
yes: true,
|
|
1331
|
+
data: {
|
|
1332
|
+
organization_id: org.id,
|
|
1333
|
+
resource_type_slug: resource.type,
|
|
1334
|
+
external_id: resource.externalId,
|
|
1335
|
+
name: resource.name,
|
|
1336
|
+
...(resource.parentExternalId && resource.parentType !== "organization" ? { parent_resource_external_id: resource.parentExternalId, parent_resource_type_slug: resource.parentType } : {}),
|
|
1337
|
+
},
|
|
1338
|
+
});
|
|
1339
|
+
if (!created.ok) {
|
|
1340
|
+
out.ok = false;
|
|
1341
|
+
out.errors.push({ operation: "resource.create", externalId: resource.externalId, resourceTypeSlug: resource.type, status: created.status, message: created.stderr || JSON.stringify(created.data) });
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
out.resources.push({ externalId: resource.externalId, resourceTypeSlug: resource.type, organizationId: org.id, status: "created", id: created.data?.id });
|
|
1345
|
+
}
|
|
1346
|
+
if (payload.action === "prove") {
|
|
1347
|
+
const memberships = readMembershipMap(payload.manifest.organizations);
|
|
1348
|
+
for (const scenario of payload.manifest.proofScenarios) {
|
|
1349
|
+
const membershipId = memberships.get(scenario.organization) || memberships.get(slugify(scenario.organization));
|
|
1350
|
+
if (!membershipId) {
|
|
1351
|
+
out.ok = false;
|
|
1352
|
+
out.errors.push({
|
|
1353
|
+
operation: "authorization.check",
|
|
1354
|
+
scenario: scenario.name,
|
|
1355
|
+
organization: scenario.organization,
|
|
1356
|
+
expected: scenario.expected,
|
|
1357
|
+
message: "missing organizationMembershipId for WorkOS FGA proof scenario",
|
|
1358
|
+
env: ["WORKOS_FGA_MEMBERSHIPS_JSON", membershipEnvKey(scenario.organization)],
|
|
1359
|
+
});
|
|
1360
|
+
continue;
|
|
1361
|
+
}
|
|
1362
|
+
const checked = cliApi("/authorization/organization_memberships/" + encodePathPart(membershipId) + "/check", {
|
|
1363
|
+
method: "POST",
|
|
1364
|
+
yes: true,
|
|
1365
|
+
data: {
|
|
1366
|
+
permission_slug: scenario.permission,
|
|
1367
|
+
resource_external_id: scenario.resourceExternalId,
|
|
1368
|
+
resource_type_slug: scenario.resourceTypeSlug,
|
|
1369
|
+
},
|
|
1370
|
+
});
|
|
1371
|
+
if (!checked.ok) {
|
|
1372
|
+
out.ok = false;
|
|
1373
|
+
out.errors.push({ operation: "authorization.check", scenario: scenario.name, status: checked.status, message: checked.stderr || JSON.stringify(checked.data) });
|
|
1374
|
+
continue;
|
|
1375
|
+
}
|
|
1376
|
+
const expectedAuthorized = scenario.expected === "allow";
|
|
1377
|
+
const passed = Boolean(checked.data?.authorized) === expectedAuthorized;
|
|
1378
|
+
if (!passed) out.ok = false;
|
|
1379
|
+
out.checks.push({ name: scenario.name, organization: scenario.organization, expected: scenario.expected, authorized: Boolean(checked.data?.authorized), ok: passed });
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
try {
|
|
1384
|
+
if (!process.env.WORKOS_API_KEY) {
|
|
1385
|
+
await runWithWorkOSCli();
|
|
1386
|
+
console.log(JSON.stringify(out));
|
|
1387
|
+
process.exit(out.ok ? 0 : 1);
|
|
1388
|
+
}
|
|
1389
|
+
const mod = await import("@workos-inc/node");
|
|
1390
|
+
const WorkOS = mod.WorkOS || mod.default?.WorkOS;
|
|
1391
|
+
if (!WorkOS) throw new Error("@workos-inc/node did not export WorkOS");
|
|
1392
|
+
const workos = new WorkOS(process.env.WORKOS_API_KEY);
|
|
1393
|
+
const orgs = await listAll(await workos.organizations.listOrganizations());
|
|
1394
|
+
const orgByName = new Map(orgs.map((org) => [org.name, org]));
|
|
1395
|
+
const sortedResources = [...payload.manifest.resources].sort((a, b) => {
|
|
1396
|
+
if (a.type === "organization") return -1;
|
|
1397
|
+
if (b.type === "organization") return 1;
|
|
1398
|
+
if (!a.parentExternalId && b.parentExternalId) return -1;
|
|
1399
|
+
if (a.parentExternalId && !b.parentExternalId) return 1;
|
|
1400
|
+
return a.externalId.localeCompare(b.externalId);
|
|
1401
|
+
});
|
|
1402
|
+
for (const resource of sortedResources) {
|
|
1403
|
+
if (resource.type === "organization") {
|
|
1404
|
+
out.resources.push({ externalId: resource.externalId, resourceTypeSlug: resource.type, organizationName: resource.tenant, status: "root-organization" });
|
|
1405
|
+
continue;
|
|
1406
|
+
}
|
|
1407
|
+
const org = orgByName.get(resource.tenant);
|
|
1408
|
+
if (!org) {
|
|
1409
|
+
out.ok = false;
|
|
1410
|
+
out.errors.push({ operation: "organization.lookup", organization: resource.tenant, message: "organization not found in WorkOS environment" });
|
|
1411
|
+
continue;
|
|
1412
|
+
}
|
|
1413
|
+
try {
|
|
1414
|
+
const existing = await workos.authorization.getResourceByExternalId({
|
|
1415
|
+
organizationId: org.id,
|
|
1416
|
+
resourceTypeSlug: resource.type,
|
|
1417
|
+
externalId: resource.externalId,
|
|
1418
|
+
});
|
|
1419
|
+
out.resources.push({ externalId: resource.externalId, resourceTypeSlug: resource.type, organizationId: org.id, status: "existing", id: existing.id });
|
|
1420
|
+
} catch (error) {
|
|
1421
|
+
if (![404, "404"].includes(status(error))) {
|
|
1422
|
+
out.ok = false;
|
|
1423
|
+
out.errors.push({ operation: "resource.get", externalId: resource.externalId, resourceTypeSlug: resource.type, status: status(error), message: message(error) });
|
|
1424
|
+
continue;
|
|
1425
|
+
}
|
|
1426
|
+
try {
|
|
1427
|
+
const created = await workos.authorization.createResource({
|
|
1428
|
+
organizationId: org.id,
|
|
1429
|
+
resourceTypeSlug: resource.type,
|
|
1430
|
+
externalId: resource.externalId,
|
|
1431
|
+
name: resource.name,
|
|
1432
|
+
...(resource.parentExternalId && resource.parentType !== "organization" ? { parentResourceExternalId: resource.parentExternalId, parentResourceTypeSlug: resource.parentType } : {}),
|
|
1433
|
+
});
|
|
1434
|
+
out.resources.push({ externalId: resource.externalId, resourceTypeSlug: resource.type, organizationId: org.id, status: "created", id: created.id });
|
|
1435
|
+
} catch (createError) {
|
|
1436
|
+
out.ok = false;
|
|
1437
|
+
out.errors.push({ operation: "resource.create", externalId: resource.externalId, resourceTypeSlug: resource.type, status: status(createError), message: message(createError) });
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
if (payload.action === "prove") {
|
|
1442
|
+
const memberships = readMembershipMap(payload.manifest.organizations);
|
|
1443
|
+
for (const scenario of payload.manifest.proofScenarios) {
|
|
1444
|
+
const membershipId = memberships.get(scenario.organization) || memberships.get(slugify(scenario.organization));
|
|
1445
|
+
if (!membershipId) {
|
|
1446
|
+
out.ok = false;
|
|
1447
|
+
out.errors.push({
|
|
1448
|
+
operation: "authorization.check",
|
|
1449
|
+
scenario: scenario.name,
|
|
1450
|
+
organization: scenario.organization,
|
|
1451
|
+
expected: scenario.expected,
|
|
1452
|
+
message: "missing organizationMembershipId for WorkOS FGA proof scenario",
|
|
1453
|
+
env: ["WORKOS_FGA_MEMBERSHIPS_JSON", membershipEnvKey(scenario.organization)],
|
|
1454
|
+
});
|
|
1455
|
+
continue;
|
|
1456
|
+
}
|
|
1457
|
+
try {
|
|
1458
|
+
const check = await workos.authorization.check({
|
|
1459
|
+
organizationMembershipId: membershipId,
|
|
1460
|
+
permissionSlug: scenario.permission,
|
|
1461
|
+
resourceExternalId: scenario.resourceExternalId,
|
|
1462
|
+
resourceTypeSlug: scenario.resourceTypeSlug,
|
|
1463
|
+
});
|
|
1464
|
+
const expectedAuthorized = scenario.expected === "allow";
|
|
1465
|
+
const passed = Boolean(check.authorized) === expectedAuthorized;
|
|
1466
|
+
if (!passed) out.ok = false;
|
|
1467
|
+
out.checks.push({ name: scenario.name, organization: scenario.organization, expected: scenario.expected, authorized: Boolean(check.authorized), ok: passed });
|
|
1468
|
+
} catch (checkError) {
|
|
1469
|
+
out.ok = false;
|
|
1470
|
+
out.errors.push({ operation: "authorization.check", scenario: scenario.name, status: status(checkError), message: message(checkError) });
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
} catch (error) {
|
|
1475
|
+
out.ok = false;
|
|
1476
|
+
out.errors.push({ operation: "sdk", message: message(error), status: status(error) });
|
|
1477
|
+
}
|
|
1478
|
+
console.log(JSON.stringify(out));
|
|
1479
|
+
process.exit(out.ok ? 0 : 1);
|
|
1480
|
+
`;
|
|
1481
|
+
const command = ["node", "--input-type=module", "-e", script];
|
|
1482
|
+
const displayCommand = ["node", "--input-type=module", "-e", "<forge-workos-fga-sdk>"];
|
|
1483
|
+
const previousPayload = process.env.FORGE_WORKOS_FGA_PAYLOAD;
|
|
1484
|
+
let child: { status: number | null; stdout: string; stderr: string };
|
|
1485
|
+
try {
|
|
1486
|
+
process.env.FORGE_WORKOS_FGA_PAYLOAD = JSON.stringify({ action, manifest });
|
|
1487
|
+
child = runExternalCommand(command, {
|
|
1488
|
+
...options,
|
|
1489
|
+
commandRunner: options.commandRunner ?? spawnSync,
|
|
1490
|
+
});
|
|
1491
|
+
} finally {
|
|
1492
|
+
if (previousPayload === undefined) {
|
|
1493
|
+
delete process.env.FORGE_WORKOS_FGA_PAYLOAD;
|
|
1494
|
+
} else {
|
|
1495
|
+
process.env.FORGE_WORKOS_FGA_PAYLOAD = previousPayload;
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
const parsed = parseJsonObject(child.stdout);
|
|
1499
|
+
return {
|
|
1500
|
+
ok: child.status === 0 && parsed?.ok === true,
|
|
1501
|
+
command: displayCommand,
|
|
1502
|
+
data: parsed ?? {
|
|
1503
|
+
ok: false,
|
|
1504
|
+
errors: [{ operation: "sdk.parse", message: "failed to parse WorkOS FGA SDK output" }],
|
|
1505
|
+
},
|
|
1506
|
+
status: child.status,
|
|
1507
|
+
stdout: child.stdout,
|
|
1508
|
+
stderr: child.stderr,
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
function workOSFgaSdkFailureDetail(data: unknown, manifest?: WorkOSFgaManifest): string {
|
|
1513
|
+
const text = JSON.stringify(data);
|
|
1514
|
+
if (text.includes("AuthorizationResourceType not found")) {
|
|
1515
|
+
const missing = extractMissingWorkOSFgaResourceTypes(data, manifest);
|
|
1516
|
+
const suffix = missing.length > 0 ? `: ${missing.join(", ")}` : "";
|
|
1517
|
+
return `WorkOS Authorization API returned missing FGA resource type(s)${suffix}; configure them in hosted WorkOS, then rerun forge workos fga sync --real --json`;
|
|
1518
|
+
}
|
|
1519
|
+
if (text.includes("Cannot add resource to organization resource type")) {
|
|
1520
|
+
return "WorkOS treats organizations as tenant roots, not creatable authorization resources; Forge will keep organization in the graph and skip resource creation for it";
|
|
1521
|
+
}
|
|
1522
|
+
if (text.includes("missing organizationMembershipId") || text.includes("WORKOS_FGA_MEMBERSHIPS_JSON")) {
|
|
1523
|
+
const env = manifest?.organizations.map(fgaMembershipEnvKey).join(", ") || "WORKOS_FGA_MEMBERSHIP_<ORG>";
|
|
1524
|
+
return `WorkOS FGA real proof requires organizationMembershipId values per organization; set WORKOS_FGA_MEMBERSHIPS_JSON or ${env}`;
|
|
1525
|
+
}
|
|
1526
|
+
return "WorkOS Authorization API resource sync failed; inspect workosSdk.errors";
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
function workOSFgaSdkProofComplete(data: unknown, manifest: WorkOSFgaManifest): boolean {
|
|
1530
|
+
if (!data || typeof data !== "object") return false;
|
|
1531
|
+
const record = data as { ok?: unknown; checks?: unknown; skipped?: unknown; errors?: unknown };
|
|
1532
|
+
if (record.ok !== true) return false;
|
|
1533
|
+
if (Array.isArray(record.errors) && record.errors.length > 0) return false;
|
|
1534
|
+
if (Array.isArray(record.skipped) && record.skipped.length > 0) return false;
|
|
1535
|
+
if (!Array.isArray(record.checks)) return false;
|
|
1536
|
+
const checks = record.checks as Array<{ name?: unknown; ok?: unknown }>;
|
|
1537
|
+
const checkByName = new Map(checks.map((check) => [String(check.name ?? ""), check]));
|
|
1538
|
+
return manifest.proofScenarios.every((scenario) => checkByName.get(scenario.name)?.ok === true);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
394
1541
|
export interface WorkOSDoctorData {
|
|
395
1542
|
seed: WorkOSSeedSummary;
|
|
396
1543
|
seedState: WorkOSSeedStateSummary;
|
|
1544
|
+
fgaManifest: WorkOSFgaManifest;
|
|
1545
|
+
fgaState: WorkOSFgaStateSummary;
|
|
397
1546
|
activePermissions: string[];
|
|
398
1547
|
expectedResourceTypes: string[];
|
|
399
1548
|
missingSeedPermissions: string[];
|
|
@@ -407,6 +1556,8 @@ function collectWorkOSDoctorData(
|
|
|
407
1556
|
): WorkOSDoctorData {
|
|
408
1557
|
const seed = parseSeedFile(workspaceRoot, preferredSeedPath);
|
|
409
1558
|
const seedState = readWorkOSSeedState(workspaceRoot, seed);
|
|
1559
|
+
const fgaManifest = collectWorkOSFgaManifest(workspaceRoot, preferredSeedPath);
|
|
1560
|
+
const fgaState = readWorkOSFgaState(workspaceRoot, fgaManifest);
|
|
410
1561
|
const activePermissions = collectPolicyPermissions(workspaceRoot);
|
|
411
1562
|
const expectedResourceTypes = collectExpectedResourceTypes(workspaceRoot);
|
|
412
1563
|
const missingSeedPermissions = missingValues(activePermissions, seed.permissions);
|
|
@@ -417,6 +1568,8 @@ function collectWorkOSDoctorData(
|
|
|
417
1568
|
return {
|
|
418
1569
|
seed,
|
|
419
1570
|
seedState,
|
|
1571
|
+
fgaManifest,
|
|
1572
|
+
fgaState,
|
|
420
1573
|
activePermissions,
|
|
421
1574
|
expectedResourceTypes,
|
|
422
1575
|
missingSeedPermissions,
|
|
@@ -453,6 +1606,8 @@ function collectWorkOSChecks(workspaceRoot: string, preferredSeedPath = DEFAULT_
|
|
|
453
1606
|
const {
|
|
454
1607
|
seed,
|
|
455
1608
|
seedState,
|
|
1609
|
+
fgaManifest,
|
|
1610
|
+
fgaState,
|
|
456
1611
|
activePermissions,
|
|
457
1612
|
expectedResourceTypes,
|
|
458
1613
|
missingSeedPermissions,
|
|
@@ -621,6 +1776,24 @@ function collectWorkOSChecks(workspaceRoot: string, preferredSeedPath = DEFAULT_
|
|
|
621
1776
|
? `${WORKOS_SEED_STATE_FILE} exists but does not match current ${seed.path}; rerun forge workos seed --file ${seed.path} --json after reviewing seed changes`
|
|
622
1777
|
: `${WORKOS_SEED_STATE_FILE} exists but is invalid: ${seedState.diagnostics.join("; ")}`,
|
|
623
1778
|
},
|
|
1779
|
+
{
|
|
1780
|
+
name: "fga-plan",
|
|
1781
|
+
ok: fgaManifest.diagnostics.length === 0,
|
|
1782
|
+
detail: fgaManifest.diagnostics.length === 0
|
|
1783
|
+
? `FGA plan covers ${fgaManifest.resourceTypes.length} resource type(s), ${fgaManifest.resources.length} resource(s), and ${fgaManifest.proofScenarios.length} proof scenario(s)`
|
|
1784
|
+
: `FGA plan has gap(s): ${fgaManifest.diagnostics.join("; ")}`,
|
|
1785
|
+
},
|
|
1786
|
+
{
|
|
1787
|
+
name: "fga-state",
|
|
1788
|
+
ok: true,
|
|
1789
|
+
detail: !fgaState.exists
|
|
1790
|
+
? `${WORKOS_FGA_STATE_FILE} not found; run forge workos fga sync --json before production deploy`
|
|
1791
|
+
: fgaState.matchesManifestHash
|
|
1792
|
+
? `${WORKOS_FGA_STATE_FILE} matches current FGA manifest${fgaState.mode === "real" ? " in real mode" : ""}`
|
|
1793
|
+
: fgaState.valid
|
|
1794
|
+
? `${WORKOS_FGA_STATE_FILE} exists but does not match current FGA manifest; rerun forge workos fga sync --json`
|
|
1795
|
+
: `${WORKOS_FGA_STATE_FILE} exists but is invalid: ${fgaState.diagnostics.join("; ")}`,
|
|
1796
|
+
},
|
|
624
1797
|
{
|
|
625
1798
|
name: "authkit-routes",
|
|
626
1799
|
ok: includesAll(authRoutes, ["handleWorkOSAuthRequest", "/login", "/callback", "/logout", "/session"]),
|
|
@@ -705,6 +1878,7 @@ function seedData(input: {
|
|
|
705
1878
|
seedAlreadyApplied?: boolean;
|
|
706
1879
|
seedAlreadyAppliedReason?: string;
|
|
707
1880
|
seedStateFile?: string;
|
|
1881
|
+
cliAuth?: WorkOSCliAuthSummary;
|
|
708
1882
|
workosCli?: Record<string, unknown>;
|
|
709
1883
|
configActions?: WorkOSConfigActionResult[];
|
|
710
1884
|
nextCommand?: string;
|
|
@@ -720,6 +1894,7 @@ function seedData(input: {
|
|
|
720
1894
|
seedAlreadyApplied: input.seedAlreadyApplied ?? false,
|
|
721
1895
|
...(input.seedAlreadyAppliedReason ? { seedAlreadyAppliedReason: input.seedAlreadyAppliedReason } : {}),
|
|
722
1896
|
...(input.seedStateFile ? { seedStateFile: input.seedStateFile } : {}),
|
|
1897
|
+
...(input.cliAuth ? { cliAuth: input.cliAuth } : {}),
|
|
723
1898
|
...(input.workosCli ? { workosCli: input.workosCli } : {}),
|
|
724
1899
|
configActions: input.configActions ?? [],
|
|
725
1900
|
...(input.nextCommand ? { nextCommand: input.nextCommand } : {}),
|
|
@@ -1057,6 +2232,26 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
|
|
|
1057
2232
|
exitCode: 0,
|
|
1058
2233
|
};
|
|
1059
2234
|
}
|
|
2235
|
+
const cliAuth = ensureWorkOSCliAuthForHosted(options);
|
|
2236
|
+
if (!cliAuth.ok) {
|
|
2237
|
+
return {
|
|
2238
|
+
ok: false,
|
|
2239
|
+
kind: "workos-seed",
|
|
2240
|
+
checks: [...checks, workOSCliAuthCheck(cliAuth)],
|
|
2241
|
+
command: cliAuth.loginCommand ?? cliAuth.statusCommand ?? command,
|
|
2242
|
+
applied: false,
|
|
2243
|
+
data: seedData({
|
|
2244
|
+
seed,
|
|
2245
|
+
activePermissions,
|
|
2246
|
+
expectedResourceTypes,
|
|
2247
|
+
unusedSeedPermissions,
|
|
2248
|
+
seedState,
|
|
2249
|
+
cliAuth,
|
|
2250
|
+
nextCommand: `forge workos seed --file ${file} --json`,
|
|
2251
|
+
}),
|
|
2252
|
+
exitCode: 1,
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
1060
2255
|
const preparedSeed = prepareSeedFileForWorkOSCli(options.workspaceRoot, seed.path);
|
|
1061
2256
|
const delegatedCommand = [
|
|
1062
2257
|
"npx",
|
|
@@ -1080,11 +2275,12 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
|
|
|
1080
2275
|
seed,
|
|
1081
2276
|
activePermissions,
|
|
1082
2277
|
expectedResourceTypes,
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
2278
|
+
unusedSeedPermissions,
|
|
2279
|
+
seedState,
|
|
2280
|
+
cliAuth,
|
|
2281
|
+
seedFileSanitized: preparedSeed.sanitized,
|
|
2282
|
+
configActions,
|
|
2283
|
+
}),
|
|
1088
2284
|
exitCode: 1,
|
|
1089
2285
|
};
|
|
1090
2286
|
}
|
|
@@ -1118,6 +2314,7 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
|
|
|
1118
2314
|
expectedResourceTypes,
|
|
1119
2315
|
unusedSeedPermissions,
|
|
1120
2316
|
seedState: latestSeedState,
|
|
2317
|
+
cliAuth,
|
|
1121
2318
|
seedFileSanitized: preparedSeed.sanitized,
|
|
1122
2319
|
seedAlreadyApplied,
|
|
1123
2320
|
seedAlreadyAppliedReason,
|
|
@@ -1141,7 +2338,13 @@ export function runWorkOSSeedCommand(options: WorkOSCommandOptions): WorkOSComma
|
|
|
1141
2338
|
export function runWorkOSSetupCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
|
|
1142
2339
|
const file = options.file ?? DEFAULT_SEED_FILE;
|
|
1143
2340
|
const checks = collectWorkOSChecks(options.workspaceRoot);
|
|
1144
|
-
const
|
|
2341
|
+
const cliAuth = options.real ? ensureWorkOSCliAuthForHosted(options) : undefined;
|
|
2342
|
+
const realEnvChecks = options.real ? collectWorkOSRealEnvChecks(options.workspaceRoot, cliAuth) : [];
|
|
2343
|
+
const allChecks = [...checks, ...realEnvChecks];
|
|
2344
|
+
if (options.real && cliAuth && !cliAuth.ok) {
|
|
2345
|
+
allChecks.push(workOSCliAuthCheck(cliAuth));
|
|
2346
|
+
}
|
|
2347
|
+
const localOk = allChecks.every((check) => check.ok);
|
|
1145
2348
|
const seed = parseSeedFile(options.workspaceRoot, file);
|
|
1146
2349
|
const seedState = readWorkOSSeedState(options.workspaceRoot, seed);
|
|
1147
2350
|
const setupDryRun = options.dryRun || !options.real;
|
|
@@ -1151,7 +2354,7 @@ export function runWorkOSSetupCommand(options: WorkOSCommandOptions): WorkOSComm
|
|
|
1151
2354
|
return {
|
|
1152
2355
|
ok: false,
|
|
1153
2356
|
kind: "workos-setup",
|
|
1154
|
-
checks,
|
|
2357
|
+
checks: allChecks,
|
|
1155
2358
|
command,
|
|
1156
2359
|
applied: false,
|
|
1157
2360
|
data: {
|
|
@@ -1159,8 +2362,11 @@ export function runWorkOSSetupCommand(options: WorkOSCommandOptions): WorkOSComm
|
|
|
1159
2362
|
real: options.real ?? false,
|
|
1160
2363
|
seed,
|
|
1161
2364
|
seedState,
|
|
2365
|
+
...(cliAuth ? { cliAuth } : {}),
|
|
1162
2366
|
configActions,
|
|
1163
|
-
nextCommand:
|
|
2367
|
+
nextCommand: cliAuth && !cliAuth.ok
|
|
2368
|
+
? `forge workos setup --real --file ${file} --json`
|
|
2369
|
+
: "forge workos doctor --json",
|
|
1164
2370
|
},
|
|
1165
2371
|
exitCode: 1,
|
|
1166
2372
|
};
|
|
@@ -1177,6 +2383,7 @@ export function runWorkOSSetupCommand(options: WorkOSCommandOptions): WorkOSComm
|
|
|
1177
2383
|
real: false,
|
|
1178
2384
|
seed,
|
|
1179
2385
|
seedState,
|
|
2386
|
+
...(cliAuth ? { cliAuth } : {}),
|
|
1180
2387
|
configActions,
|
|
1181
2388
|
nextCommand: `forge workos setup --real --file ${file} --json`,
|
|
1182
2389
|
},
|
|
@@ -1195,12 +2402,13 @@ export function runWorkOSSetupCommand(options: WorkOSCommandOptions): WorkOSComm
|
|
|
1195
2402
|
return {
|
|
1196
2403
|
ok: seedResult.ok,
|
|
1197
2404
|
kind: "workos-setup",
|
|
1198
|
-
checks: seedResult.checks,
|
|
2405
|
+
checks: [...seedResult.checks, ...realEnvChecks],
|
|
1199
2406
|
command: seedResult.command,
|
|
1200
2407
|
applied: seedResult.ok,
|
|
1201
2408
|
data: {
|
|
1202
2409
|
real: true,
|
|
1203
2410
|
seed,
|
|
2411
|
+
...(cliAuth ? { cliAuth } : {}),
|
|
1204
2412
|
seedState: seedResultData.seedState ?? readWorkOSSeedState(options.workspaceRoot, seed),
|
|
1205
2413
|
seedResult: seedResult.data,
|
|
1206
2414
|
nextCommand: "forge workos doctor --json",
|
|
@@ -1233,6 +2441,9 @@ export function runWorkOSProveCommand(options: WorkOSCommandOptions): WorkOSComm
|
|
|
1233
2441
|
real: options.real ?? false,
|
|
1234
2442
|
file,
|
|
1235
2443
|
});
|
|
2444
|
+
const setupBlockingChecks = setup.checks
|
|
2445
|
+
.filter((check) => !check.ok)
|
|
2446
|
+
.map((check) => ({ ...check, name: `setup:${check.name}` }));
|
|
1236
2447
|
const checks: WorkOSCheck[] = [
|
|
1237
2448
|
...doctor.checks.map((check) => ({ ...check, name: `doctor:${check.name}` })),
|
|
1238
2449
|
{
|
|
@@ -1242,6 +2453,7 @@ export function runWorkOSProveCommand(options: WorkOSCommandOptions): WorkOSComm
|
|
|
1242
2453
|
? `${file} is valid, app-aware, and ready for hosted application`
|
|
1243
2454
|
: `${file} failed seed validation before hosted application`,
|
|
1244
2455
|
},
|
|
2456
|
+
...setupBlockingChecks,
|
|
1245
2457
|
{
|
|
1246
2458
|
name: options.real ? "setup:real" : "setup:dry-run",
|
|
1247
2459
|
ok: setup.ok,
|
|
@@ -1277,7 +2489,320 @@ export function runWorkOSProveCommand(options: WorkOSCommandOptions): WorkOSComm
|
|
|
1277
2489
|
};
|
|
1278
2490
|
}
|
|
1279
2491
|
|
|
2492
|
+
function collectWorkOSFgaChecks(input: {
|
|
2493
|
+
manifest: WorkOSFgaManifest;
|
|
2494
|
+
state: WorkOSFgaStateSummary;
|
|
2495
|
+
requireState?: boolean;
|
|
2496
|
+
requireRealState?: boolean;
|
|
2497
|
+
requireProof?: boolean;
|
|
2498
|
+
}): WorkOSCheck[] {
|
|
2499
|
+
return [
|
|
2500
|
+
{
|
|
2501
|
+
name: "fga-manifest",
|
|
2502
|
+
ok: input.manifest.diagnostics.length === 0,
|
|
2503
|
+
detail: input.manifest.diagnostics.length === 0
|
|
2504
|
+
? `manifest ${input.manifest.manifestHash.slice(0, 12)} covers ${input.manifest.resourceTypes.length} resource type(s) and ${input.manifest.resources.length} resource(s)`
|
|
2505
|
+
: input.manifest.diagnostics.join("; "),
|
|
2506
|
+
},
|
|
2507
|
+
{
|
|
2508
|
+
name: "fga-resource-types",
|
|
2509
|
+
ok: input.manifest.resourceTypes.length > 0 && input.manifest.resourceTypes.includes("organization"),
|
|
2510
|
+
detail: input.manifest.resourceTypes.length > 0
|
|
2511
|
+
? `resource types: ${input.manifest.resourceTypes.join(", ")}`
|
|
2512
|
+
: "at least organization plus app resource types are required",
|
|
2513
|
+
},
|
|
2514
|
+
{
|
|
2515
|
+
name: "fga-proof-scenarios",
|
|
2516
|
+
ok: input.manifest.proofScenarios.some((scenario) => scenario.expected === "allow") &&
|
|
2517
|
+
input.manifest.proofScenarios.some((scenario) => scenario.expected === "deny"),
|
|
2518
|
+
detail: `proof scenarios: ${input.manifest.proofScenarios.map((scenario) => `${scenario.name}:${scenario.expected}`).join(", ") || "none"}`,
|
|
2519
|
+
},
|
|
2520
|
+
{
|
|
2521
|
+
name: "fga-state",
|
|
2522
|
+
ok: !input.requireState || Boolean(input.state.exists && input.state.valid && input.state.matchesManifestHash === true),
|
|
2523
|
+
detail: !input.state.exists
|
|
2524
|
+
? `${WORKOS_FGA_STATE_FILE} is missing`
|
|
2525
|
+
: !input.state.valid
|
|
2526
|
+
? `${WORKOS_FGA_STATE_FILE} is invalid: ${input.state.diagnostics.join("; ")}`
|
|
2527
|
+
: input.state.matchesManifestHash
|
|
2528
|
+
? `${WORKOS_FGA_STATE_FILE} matches current manifest`
|
|
2529
|
+
: `${WORKOS_FGA_STATE_FILE} is stale or invalid: ${input.state.diagnostics.join("; ") || "manifest hash mismatch"}`,
|
|
2530
|
+
},
|
|
2531
|
+
{
|
|
2532
|
+
name: "fga-real-state",
|
|
2533
|
+
ok: !input.requireRealState || input.state.mode === "real",
|
|
2534
|
+
detail: input.state.mode === "real"
|
|
2535
|
+
? `${WORKOS_FGA_STATE_FILE} records real sync/proof mode`
|
|
2536
|
+
: input.requireRealState
|
|
2537
|
+
? `${WORKOS_FGA_STATE_FILE} must be produced by forge workos fga sync --real --json`
|
|
2538
|
+
: `${WORKOS_FGA_STATE_FILE} real mode not required for this command`,
|
|
2539
|
+
},
|
|
2540
|
+
{
|
|
2541
|
+
name: "fga-proof-state",
|
|
2542
|
+
ok: !input.requireProof || Boolean(input.state.provedAt),
|
|
2543
|
+
detail: input.state.provedAt
|
|
2544
|
+
? `${WORKOS_FGA_STATE_FILE} records real proof at ${input.state.provedAt}`
|
|
2545
|
+
: input.requireProof
|
|
2546
|
+
? `${WORKOS_FGA_STATE_FILE} must be produced by forge workos fga prove --real --json`
|
|
2547
|
+
: `${WORKOS_FGA_STATE_FILE} proof timestamp not required for this command`,
|
|
2548
|
+
},
|
|
2549
|
+
];
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
export function runWorkOSFgaCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
|
|
2553
|
+
const action = options.fgaAction ?? "doctor";
|
|
2554
|
+
const file = options.file ?? DEFAULT_SEED_FILE;
|
|
2555
|
+
const manifest = collectWorkOSFgaManifest(options.workspaceRoot, file);
|
|
2556
|
+
const initialHostedSetup = workOSFgaHostedSetup(manifest);
|
|
2557
|
+
const setupGuidePath = resolveWorkOSFgaSetupGuidePath(options);
|
|
2558
|
+
const writtenSetupGuidePath = setupGuidePath
|
|
2559
|
+
? writeWorkOSFgaSetupGuide(options.workspaceRoot, workOSFgaSetupGuide(manifest, initialHostedSetup), setupGuidePath)
|
|
2560
|
+
: undefined;
|
|
2561
|
+
let state = readWorkOSFgaState(options.workspaceRoot, manifest);
|
|
2562
|
+
const command = ["forge", "workos", "fga", action, "--file", file];
|
|
2563
|
+
const real = options.real ?? false;
|
|
2564
|
+
const requireState = action === "prove" || action === "doctor";
|
|
2565
|
+
const requireRealState = real && action !== "plan";
|
|
2566
|
+
const requireProof = real && action === "doctor";
|
|
2567
|
+
const seed = parseSeedFile(options.workspaceRoot, file);
|
|
2568
|
+
const seedState = readWorkOSSeedState(options.workspaceRoot, seed);
|
|
2569
|
+
const checks: WorkOSCheck[] = collectWorkOSFgaChecks({
|
|
2570
|
+
manifest,
|
|
2571
|
+
state,
|
|
2572
|
+
requireState,
|
|
2573
|
+
requireRealState,
|
|
2574
|
+
requireProof,
|
|
2575
|
+
});
|
|
2576
|
+
|
|
2577
|
+
if (action === "plan") {
|
|
2578
|
+
const ok = checks.filter((check) => check.name !== "fga-state" && check.name !== "fga-real-state" && check.name !== "fga-proof-state").every((check) => check.ok);
|
|
2579
|
+
return {
|
|
2580
|
+
ok,
|
|
2581
|
+
kind: "workos-fga",
|
|
2582
|
+
checks,
|
|
2583
|
+
command,
|
|
2584
|
+
applied: false,
|
|
2585
|
+
data: fgaData({
|
|
2586
|
+
action,
|
|
2587
|
+
manifest,
|
|
2588
|
+
state,
|
|
2589
|
+
real,
|
|
2590
|
+
...(writtenSetupGuidePath ? { setupGuidePath: writtenSetupGuidePath } : {}),
|
|
2591
|
+
nextCommand: `forge workos fga sync --file ${file} --json`,
|
|
2592
|
+
}),
|
|
2593
|
+
exitCode: ok ? 0 : 1,
|
|
2594
|
+
};
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
if (action === "sync") {
|
|
2598
|
+
const cliAuth = real ? ensureWorkOSCliAuthForHosted(options) : undefined;
|
|
2599
|
+
const realChecks: WorkOSCheck[] = real
|
|
2600
|
+
? [
|
|
2601
|
+
...(cliAuth && !cliAuth.ok ? [workOSCliAuthCheck(cliAuth)] : []),
|
|
2602
|
+
{
|
|
2603
|
+
name: "fga-seed-state",
|
|
2604
|
+
ok: Boolean(seedState.exists && seedState.valid && seedState.matchesSeedHash === true),
|
|
2605
|
+
detail: seedState.matchesSeedHash
|
|
2606
|
+
? `${WORKOS_SEED_STATE_FILE} matches ${seed.path}`
|
|
2607
|
+
: `real FGA sync requires hosted seed evidence; run forge workos prove --real --file ${file} --json first`,
|
|
2608
|
+
},
|
|
2609
|
+
]
|
|
2610
|
+
: [];
|
|
2611
|
+
const allChecks = [...checks.filter((check) => check.name !== "fga-state" && check.name !== "fga-real-state" && check.name !== "fga-proof-state"), ...realChecks];
|
|
2612
|
+
if (!allChecks.every((check) => check.ok)) {
|
|
2613
|
+
return {
|
|
2614
|
+
ok: false,
|
|
2615
|
+
kind: "workos-fga",
|
|
2616
|
+
checks: allChecks,
|
|
2617
|
+
command: cliAuth && !cliAuth.ok ? cliAuth.loginCommand ?? cliAuth.statusCommand : command,
|
|
2618
|
+
applied: false,
|
|
2619
|
+
data: fgaData({
|
|
2620
|
+
action,
|
|
2621
|
+
manifest,
|
|
2622
|
+
state,
|
|
2623
|
+
real,
|
|
2624
|
+
...(cliAuth ? { cliAuth } : {}),
|
|
2625
|
+
...(writtenSetupGuidePath ? { setupGuidePath: writtenSetupGuidePath } : {}),
|
|
2626
|
+
nextCommand: real ? `forge workos fga sync --real --file ${file} --json` : `forge workos fga plan --file ${file} --json`,
|
|
2627
|
+
}),
|
|
2628
|
+
exitCode: 1,
|
|
2629
|
+
};
|
|
2630
|
+
}
|
|
2631
|
+
if (options.dryRun) {
|
|
2632
|
+
return {
|
|
2633
|
+
ok: true,
|
|
2634
|
+
kind: "workos-fga",
|
|
2635
|
+
checks: allChecks,
|
|
2636
|
+
command,
|
|
2637
|
+
applied: false,
|
|
2638
|
+
data: fgaData({
|
|
2639
|
+
action,
|
|
2640
|
+
manifest,
|
|
2641
|
+
state,
|
|
2642
|
+
real,
|
|
2643
|
+
...(cliAuth ? { cliAuth } : {}),
|
|
2644
|
+
...(writtenSetupGuidePath ? { setupGuidePath: writtenSetupGuidePath } : {}),
|
|
2645
|
+
nextCommand: real ? `forge workos fga sync --real --file ${file} --json` : `forge workos fga sync --file ${file} --json`,
|
|
2646
|
+
}),
|
|
2647
|
+
exitCode: 0,
|
|
2648
|
+
};
|
|
2649
|
+
}
|
|
2650
|
+
const sdk = real ? runWorkOSFgaSdk(options, manifest, "sync") : undefined;
|
|
2651
|
+
if (sdk && !sdk.ok) {
|
|
2652
|
+
return {
|
|
2653
|
+
ok: false,
|
|
2654
|
+
kind: "workos-fga",
|
|
2655
|
+
checks: [
|
|
2656
|
+
...allChecks,
|
|
2657
|
+
{
|
|
2658
|
+
name: "fga-real-sdk-sync",
|
|
2659
|
+
ok: false,
|
|
2660
|
+
detail: workOSFgaSdkFailureDetail(sdk.data, manifest),
|
|
2661
|
+
},
|
|
2662
|
+
],
|
|
2663
|
+
command: sdk.command,
|
|
2664
|
+
applied: false,
|
|
2665
|
+
data: fgaData({
|
|
2666
|
+
action,
|
|
2667
|
+
manifest,
|
|
2668
|
+
state,
|
|
2669
|
+
real,
|
|
2670
|
+
...(cliAuth ? { cliAuth } : {}),
|
|
2671
|
+
workosSdk: sdk.data,
|
|
2672
|
+
...(writtenSetupGuidePath ? { setupGuidePath: writtenSetupGuidePath } : {}),
|
|
2673
|
+
nextCommand: `forge workos fga sync --real --file ${file} --json`,
|
|
2674
|
+
nextActions: workOSFgaHostedSetup(manifest, sdk.data).nextActions,
|
|
2675
|
+
}),
|
|
2676
|
+
stdout: sdk.stdout,
|
|
2677
|
+
stderr: sdk.stderr,
|
|
2678
|
+
exitCode: 1,
|
|
2679
|
+
};
|
|
2680
|
+
}
|
|
2681
|
+
const stateFile = writeWorkOSFgaState({
|
|
2682
|
+
workspaceRoot: options.workspaceRoot,
|
|
2683
|
+
manifest,
|
|
2684
|
+
mode: real ? "real" : "local",
|
|
2685
|
+
proved: false,
|
|
2686
|
+
...(sdk ? { sdkOk: sdk.ok, sdk: sdk.data } : {}),
|
|
2687
|
+
});
|
|
2688
|
+
state = readWorkOSFgaState(options.workspaceRoot, manifest);
|
|
2689
|
+
return {
|
|
2690
|
+
ok: true,
|
|
2691
|
+
kind: "workos-fga",
|
|
2692
|
+
checks: collectWorkOSFgaChecks({ manifest, state, requireState: true, requireRealState: real }),
|
|
2693
|
+
command,
|
|
2694
|
+
applied: true,
|
|
2695
|
+
data: fgaData({
|
|
2696
|
+
action,
|
|
2697
|
+
manifest,
|
|
2698
|
+
state,
|
|
2699
|
+
real,
|
|
2700
|
+
...(cliAuth ? { cliAuth } : {}),
|
|
2701
|
+
...(sdk ? { workosSdk: sdk.data } : {}),
|
|
2702
|
+
stateFile,
|
|
2703
|
+
...(writtenSetupGuidePath ? { setupGuidePath: writtenSetupGuidePath } : {}),
|
|
2704
|
+
nextCommand: real ? `forge workos fga prove --real --file ${file} --json` : `forge workos fga prove --file ${file} --json`,
|
|
2705
|
+
}),
|
|
2706
|
+
exitCode: 0,
|
|
2707
|
+
};
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
if (action === "prove") {
|
|
2711
|
+
const cliAuth = real ? ensureWorkOSCliAuthForHosted(options) : undefined;
|
|
2712
|
+
const sdk = real && state.exists && state.valid && state.matchesManifestHash === true && state.mode === "real"
|
|
2713
|
+
? runWorkOSFgaSdk(options, manifest, "prove")
|
|
2714
|
+
: undefined;
|
|
2715
|
+
const sdkProofComplete = sdk ? workOSFgaSdkProofComplete(sdk.data, manifest) : false;
|
|
2716
|
+
const proofChecks: WorkOSCheck[] = [
|
|
2717
|
+
...checks,
|
|
2718
|
+
...(cliAuth && !cliAuth.ok ? [workOSCliAuthCheck(cliAuth)] : []),
|
|
2719
|
+
...(sdk
|
|
2720
|
+
? [
|
|
2721
|
+
{
|
|
2722
|
+
name: "fga-real-sdk-proof",
|
|
2723
|
+
ok: sdk.ok && sdkProofComplete,
|
|
2724
|
+
detail: sdk.ok && sdkProofComplete
|
|
2725
|
+
? "WorkOS Authorization API resource sync/check proof completed for every scenario"
|
|
2726
|
+
: workOSFgaSdkFailureDetail(sdk.data, manifest),
|
|
2727
|
+
},
|
|
2728
|
+
]
|
|
2729
|
+
: real
|
|
2730
|
+
? [
|
|
2731
|
+
{
|
|
2732
|
+
name: "fga-real-sdk-proof",
|
|
2733
|
+
ok: false,
|
|
2734
|
+
detail: "real FGA proof requires a fresh real FGA state from forge workos fga sync --real --json",
|
|
2735
|
+
},
|
|
2736
|
+
]
|
|
2737
|
+
: []),
|
|
2738
|
+
{
|
|
2739
|
+
name: "fga-cross-tenant-proof",
|
|
2740
|
+
ok: manifest.proofScenarios.some((scenario) => scenario.name.includes("cross-tenant") && scenario.expected === "deny"),
|
|
2741
|
+
detail: "FGA proof includes cross-tenant denial scenario using resourceExternalId and resourceTypeSlug",
|
|
2742
|
+
},
|
|
2743
|
+
{
|
|
2744
|
+
name: "fga-authorization-api-shape",
|
|
2745
|
+
ok: true,
|
|
2746
|
+
detail: "proof contract uses organizationMembershipId, resourceExternalId, resourceTypeSlug, and permission slugs",
|
|
2747
|
+
},
|
|
2748
|
+
];
|
|
2749
|
+
const ok = proofChecks.every((check) => check.ok);
|
|
2750
|
+
const stateFile = ok && !options.dryRun
|
|
2751
|
+
? writeWorkOSFgaState({
|
|
2752
|
+
workspaceRoot: options.workspaceRoot,
|
|
2753
|
+
manifest,
|
|
2754
|
+
mode: real ? "real" : "local",
|
|
2755
|
+
proved: true,
|
|
2756
|
+
...(sdk ? { sdkOk: sdk.ok, sdk: sdk.data } : {}),
|
|
2757
|
+
})
|
|
2758
|
+
: undefined;
|
|
2759
|
+
state = stateFile ? readWorkOSFgaState(options.workspaceRoot, manifest) : state;
|
|
2760
|
+
return {
|
|
2761
|
+
ok,
|
|
2762
|
+
kind: "workos-fga",
|
|
2763
|
+
checks: proofChecks,
|
|
2764
|
+
command: cliAuth && !cliAuth.ok ? cliAuth.loginCommand ?? cliAuth.statusCommand : command,
|
|
2765
|
+
applied: Boolean(ok && !options.dryRun),
|
|
2766
|
+
data: fgaData({
|
|
2767
|
+
action,
|
|
2768
|
+
manifest,
|
|
2769
|
+
state,
|
|
2770
|
+
real,
|
|
2771
|
+
...(cliAuth ? { cliAuth } : {}),
|
|
2772
|
+
...(sdk ? { workosSdk: sdk.data } : {}),
|
|
2773
|
+
...(stateFile ? { stateFile } : {}),
|
|
2774
|
+
...(writtenSetupGuidePath ? { setupGuidePath: writtenSetupGuidePath } : {}),
|
|
2775
|
+
nextCommand: ok ? "forge deploy check --production --json" : `forge workos fga sync${real ? " --real" : ""} --file ${file} --json`,
|
|
2776
|
+
}),
|
|
2777
|
+
stdout: sdk?.stdout,
|
|
2778
|
+
stderr: sdk?.stderr,
|
|
2779
|
+
exitCode: ok ? 0 : 1,
|
|
2780
|
+
};
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
const ok = checks.every((check) => check.ok);
|
|
2784
|
+
return {
|
|
2785
|
+
ok,
|
|
2786
|
+
kind: "workos-fga",
|
|
2787
|
+
checks,
|
|
2788
|
+
command,
|
|
2789
|
+
applied: false,
|
|
2790
|
+
data: fgaData({
|
|
2791
|
+
action,
|
|
2792
|
+
manifest,
|
|
2793
|
+
state,
|
|
2794
|
+
real,
|
|
2795
|
+
...(writtenSetupGuidePath ? { setupGuidePath: writtenSetupGuidePath } : {}),
|
|
2796
|
+
nextCommand: state.exists ? `forge workos fga prove --file ${file} --json` : `forge workos fga sync --file ${file} --json`,
|
|
2797
|
+
}),
|
|
2798
|
+
exitCode: ok ? 0 : 1,
|
|
2799
|
+
};
|
|
2800
|
+
}
|
|
2801
|
+
|
|
1280
2802
|
export function runWorkOSCommand(options: WorkOSCommandOptions): WorkOSCommandResult {
|
|
2803
|
+
if (options.subcommand === "fga") {
|
|
2804
|
+
return runWorkOSFgaCommand(options);
|
|
2805
|
+
}
|
|
1281
2806
|
if (options.subcommand === "install") {
|
|
1282
2807
|
return runWorkOSInstallCommand(options);
|
|
1283
2808
|
}
|
|
@@ -1301,6 +2826,29 @@ export function formatWorkOSHuman(result: WorkOSCommandResult): string {
|
|
|
1301
2826
|
result.ok ? "WorkOS: ok" : "WorkOS: needs attention",
|
|
1302
2827
|
...result.checks.map((check) => `${check.ok ? "ok" : "fail"} ${check.name}: ${check.detail}`),
|
|
1303
2828
|
];
|
|
2829
|
+
const dataObject = result.data && typeof result.data === "object"
|
|
2830
|
+
? result.data as {
|
|
2831
|
+
cliAuth?: WorkOSCliAuthSummary;
|
|
2832
|
+
seedResult?: { cliAuth?: WorkOSCliAuthSummary };
|
|
2833
|
+
setup?: { cliAuth?: WorkOSCliAuthSummary; seedResult?: { cliAuth?: WorkOSCliAuthSummary } };
|
|
2834
|
+
}
|
|
2835
|
+
: {};
|
|
2836
|
+
const cliAuth = dataObject.cliAuth ?? dataObject.seedResult?.cliAuth ?? dataObject.setup?.cliAuth ?? dataObject.setup?.seedResult?.cliAuth;
|
|
2837
|
+
if (cliAuth) {
|
|
2838
|
+
if (cliAuth.ok && cliAuth.method === "cli") {
|
|
2839
|
+
lines.push(`WorkOS CLI authenticated${cliAuth.email ? ` as ${cliAuth.email}` : ""}; hosted setup can proceed without opening the dashboard.`);
|
|
2840
|
+
} else if (!cliAuth.ok && cliAuth.loginInstructions) {
|
|
2841
|
+
lines.push("WorkOS CLI login required before hosted setup can proceed.");
|
|
2842
|
+
if (cliAuth.loginInstructions.url) {
|
|
2843
|
+
lines.push(`open: ${cliAuth.loginInstructions.url}`);
|
|
2844
|
+
}
|
|
2845
|
+
if (cliAuth.loginInstructions.code) {
|
|
2846
|
+
lines.push(`code: ${cliAuth.loginInstructions.code}`);
|
|
2847
|
+
}
|
|
2848
|
+
} else if (cliAuth.method === "api-key") {
|
|
2849
|
+
lines.push("WorkOS hosted setup will use WORKOS_API_KEY; CLI browser login is optional.");
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
1304
2852
|
if (result.command) {
|
|
1305
2853
|
lines.push(`command: ${result.command.join(" ")}`);
|
|
1306
2854
|
}
|
|
@@ -1362,5 +2910,25 @@ export function formatWorkOSHuman(result: WorkOSCommandResult): string {
|
|
|
1362
2910
|
lines.push("WorkOS proof failed; inspect doctor, seed, and setup details.");
|
|
1363
2911
|
}
|
|
1364
2912
|
}
|
|
2913
|
+
if (result.kind === "workos-fga") {
|
|
2914
|
+
const data = result.data && typeof result.data === "object"
|
|
2915
|
+
? result.data as { action?: string; real?: boolean; nextCommand?: string; stateFile?: string; setupGuidePath?: string; nextActions?: string[] }
|
|
2916
|
+
: {};
|
|
2917
|
+
if (data.setupGuidePath) {
|
|
2918
|
+
lines.push(`FGA setup guide: ${data.setupGuidePath}`);
|
|
2919
|
+
}
|
|
2920
|
+
if (result.ok && data.action === "plan") {
|
|
2921
|
+
lines.push(`WorkOS FGA plan passed; run ${data.nextCommand ?? "forge workos fga sync --json"}.`);
|
|
2922
|
+
} else if (result.ok && data.action === "sync") {
|
|
2923
|
+
lines.push(`WorkOS FGA sync recorded${data.stateFile ? ` in ${data.stateFile}` : ""}; run ${data.nextCommand ?? "forge workos fga prove --json"}.`);
|
|
2924
|
+
} else if (result.ok && data.action === "prove") {
|
|
2925
|
+
lines.push(`WorkOS FGA proof passed${data.real ? " for real-mode state" : " locally"}; production deploy gates can inspect the FGA state.`);
|
|
2926
|
+
} else if (!result.ok) {
|
|
2927
|
+
lines.push("WorkOS FGA check failed; inspect manifest diagnostics, seed coverage, and FGA state.");
|
|
2928
|
+
for (const action of data.nextActions ?? []) {
|
|
2929
|
+
lines.push(` - ${action}`);
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
1365
2933
|
return `${lines.join("\n")}\n`;
|
|
1366
2934
|
}
|