Repository navigation
feat(blueprint): add Okta runtime identity reference #7265
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
45 commits
Select commit
Hold shift + click to select a range
1fee71b
feat(blueprint): add Okta runtime identity wrapper
afourniernv 85292df
feat(blueprint): add Entra identity reference
afourniernv 2a7ef35
fix(blueprint): keep runtime identity Okta-only
afourniernv 590e385
fix(blueprint): harden Okta identity validation
afourniernv cb6d516
Merge branch 'main' into 6871-agent-identity-references/af
ericksoa b990376
Merge remote-tracking branch 'origin/main' into pr-7265
ericksoa 3518f36
fix(blueprint): harden runtime identity lifecycle
ericksoa d35d4c6
fix(blueprint): close identity review gaps
ericksoa 537c216
fix(blueprint): address identity review findings
ericksoa bdcd239
Merge remote-tracking branch 'origin/main' into pr-7265
ericksoa 9a484a7
test(blueprint): keep identity fixtures linear
ericksoa 182dc26
fix(blueprint): fail closed on identity DNS rebinding
ericksoa 549106e
fix(blueprint): require owned identity providers
ericksoa a78525e
fix(blueprint): constrain runtime identity policy
ericksoa 71e4496
Merge remote-tracking branch 'origin/main' into pr-7265
ericksoa 4b32c5e
fix(blueprint): import validated identity profile bytes
ericksoa 2a5c17c
test(blueprint): keep identity lifecycle linear
ericksoa 0242437
fix(identity): prove runtime OAuth flow end to end
ericksoa 7b79ce8
Merge remote-tracking branch 'origin/main' into pr-7265
ericksoa f7df500
test(identity): keep rotation proof linear
ericksoa 0bc4646
fix(identity): tighten ownership and DNS trust
ericksoa 5497274
fix(identity): bind client ID source to policy
ericksoa 72e1193
fix(identity): keep failed rollback retryable
ericksoa b07fca5
Merge remote-tracking branch 'origin/main' into pr-7265
ericksoa 7a85de9
fix(identity): reuse existing sandbox before create
ericksoa f2cbbf0
Merge remote-tracking branch 'origin/main' into pr-7265
ericksoa ff871ea
fix(identity): fail closed on sandbox inspection
ericksoa 258f034
docs(identity): document sandbox preflight order
ericksoa d717806
test(identity): keep sandbox fixture linear
ericksoa 0718158
fix(identity): reuse configured inference provider
ericksoa e0b5470
Merge remote-tracking branch 'origin/main' into pr-7265
ericksoa 616c896
docs(identity): clarify provider preflight order
ericksoa 7a35fc5
fix(identity): track inference provider ownership
ericksoa fa7a376
fix(identity): revalidate provider create races
ericksoa 8fc7df8
fix(blueprint): verify inference before identity attach
ericksoa 8ea8850
fix(blueprint): preserve active inference route
ericksoa 20c64fb
docs(architecture): clarify route timeout reuse
ericksoa 58dcf6e
test(blueprint): keep route timeout case linear
ericksoa 1734e67
fix(blueprint): revalidate raced sandbox
ericksoa ba3b1ec
fix(blueprint): recheck sandbox before identity attach
ericksoa 3d71424
fix(schema): align runtime client id names
ericksoa 0fcf798
fix(identity): prove provider policy auth path
ericksoa c5b1821
fix(identity): bound inference route parsing
ericksoa efb00ba
test(e2e): verify preserved sandbox with supported command
ericksoa 7ea7370
test(e2e): align runtime identity phase evidence
ericksoa File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| # Template profile for the experimental blueprint identity wrapper. Copy this | ||
| # file, replace the example authorization-server URL and API host, and add | ||
| # only tenant-approved API scopes under `refresh.scopes` before use. | ||
| id: okta-runtime-v1 | ||
| display_name: Okta Runtime Credentials v1 | ||
| description: Gateway-managed Okta access-token refresh for an attached sandbox | ||
| category: agent | ||
| credentials: | ||
| - name: OKTA_ACCESS_TOKEN | ||
| description: Short-lived Okta API access token | ||
| env_vars: | ||
| - OKTA_ACCESS_TOKEN | ||
| required: true | ||
| auth_style: bearer | ||
| header_name: authorization | ||
| refresh: | ||
| strategy: oauth2_refresh_token | ||
| token_url: https://example.okta.com/oauth2/default/v1/token | ||
| refresh_before_seconds: 300 | ||
| max_lifetime_seconds: 3600 | ||
| material: | ||
| - name: client_id | ||
| required: true | ||
| - name: refresh_token | ||
| required: true | ||
| secret: true | ||
| - name: client_secret | ||
| required: false | ||
| secret: true | ||
| endpoints: | ||
| - host: api.example.okta.com | ||
| port: 443 | ||
| protocol: rest | ||
| enforcement: enforce | ||
| rules: | ||
| - allow: { method: GET, path: "/**" } | ||
| binaries: | ||
| - /usr/local/bin/node | ||
| - /usr/bin/node | ||
| - /usr/local/bin/curl | ||
| - /usr/bin/curl | ||
| inference_capable: false |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,222 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import type fs from "node:fs"; | ||
|
|
||
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
| import YAML from "yaml"; | ||
|
|
||
| interface FsEntry { | ||
| type: "file" | "dir"; | ||
| content?: string; | ||
| } | ||
|
|
||
| const store = new Map<string, FsEntry>(); | ||
| const mockExeca = vi.fn(); | ||
|
|
||
| vi.mock("node:os", () => ({ homedir: () => "/fakehome" })); | ||
| vi.mock("node:crypto", () => ({ randomUUID: () => "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" })); | ||
| vi.mock("node:fs", async (importOriginal) => { | ||
| const original = await importOriginal<typeof fs>(); | ||
| return { | ||
| ...original, | ||
| mkdirSync: vi.fn((path: string) => store.set(path, { type: "dir" })), | ||
| readFileSync: (path: string) => { | ||
| const entry = store.get(path); | ||
| if (entry?.type !== "file") throw new Error(`ENOENT: ${path}`); | ||
| return entry.content ?? ""; | ||
| }, | ||
| writeFileSync: vi.fn((path: string, content: string) => | ||
| store.set(path, { type: "file", content }), | ||
| ), | ||
| readdirSync: (path: string) => { | ||
| const prefix = path.endsWith("/") ? path : `${path}/`; | ||
| const entries = [...store.keys()] | ||
| .filter((key) => key.startsWith(prefix)) | ||
| .map((key) => key.slice(prefix.length).split("/")[0]); | ||
| if (entries.length === 0 && !store.has(path)) throw new Error(`ENOENT: ${path}`); | ||
| return [...new Set(entries)]; | ||
| }, | ||
| }; | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| vi.mock("execa", () => ({ execa: (...args: unknown[]) => mockExeca(...args) })); | ||
| vi.mock("./ssrf.js", async (importOriginal) => { | ||
| const actual = await importOriginal<typeof import("./ssrf.js")>(); | ||
| return { | ||
| ...actual, | ||
| validateEndpointUrl: vi.fn(async (url: string) => ({ | ||
| url, | ||
| pinnedUrl: url, | ||
| protocol: "https:", | ||
| hostname: new URL(url).hostname, | ||
| dnsResolved: false, | ||
| })), | ||
| }; | ||
| }); | ||
|
|
||
| const { actionApply, loadBlueprint } = await import("./runner.js"); | ||
|
|
||
| function blueprint(overrides: Record<string, unknown> = {}): Parameters<typeof actionApply>[1] { | ||
| return { | ||
| components: { | ||
| inference: { | ||
| profiles: { | ||
| default: { | ||
| provider_type: "openai", | ||
| provider_name: "test-provider", | ||
| endpoint: "https://api.example.com/v1", | ||
| model: "test-model", | ||
| }, | ||
| }, | ||
| }, | ||
| sandbox: { image: "openclaw", name: "test-sandbox", forward_ports: [18789] }, | ||
| ...overrides, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function oktaIdentity(profilePath = "provider-profiles/okta-runtime-v1.yaml") { | ||
| return { | ||
| profile_path: profilePath, | ||
| provider_type: "okta-runtime-v1", | ||
| provider_name: "acme-okta-runtime", | ||
| credential_key: "OKTA_ACCESS_TOKEN", | ||
| client_id_env: "OKTA_CLIENT_ID", | ||
| refresh_token_env: "OKTA_REFRESH_TOKEN", | ||
| client_secret_env: "OKTA_CLIENT_SECRET", | ||
| }; | ||
| } | ||
|
|
||
| describe("blueprint identity wrapper", () => { | ||
| beforeEach(() => { | ||
| store.clear(); | ||
| vi.clearAllMocks(); | ||
| mockExeca.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); | ||
| delete process.env.NEMOCLAW_BLUEPRINT_PATH; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| delete process.env.OKTA_CLIENT_ID; | ||
| delete process.env.OKTA_REFRESH_TOKEN; | ||
| delete process.env.OKTA_CLIENT_SECRET; | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| it("accepts an opt-in Okta identity and fail-closed middleware configuration", () => { | ||
| const input = blueprint({ | ||
| identity: { okta: oktaIdentity() }, | ||
| policy: { | ||
| middlewares: { | ||
| identity_guard: { | ||
| middleware: "acme/identity-guard", | ||
| order: 10, | ||
| on_error: "fail_closed", | ||
| endpoints: { include: ["api.example.okta.com"] }, | ||
| }, | ||
| }, | ||
| }, | ||
| }); | ||
| store.set("blueprint.yaml", { type: "file", content: YAML.stringify(input) }); | ||
|
|
||
| expect(loadBlueprint()).toEqual(input); | ||
| }); | ||
|
|
||
| it("rejects profile paths that escape the blueprint directory", async () => { | ||
| await expect( | ||
| actionApply("default", blueprint({ identity: { okta: oktaIdentity("../outside.yaml") } })), | ||
| ).rejects.toThrow(/profile_path must stay inside/i); | ||
| }); | ||
|
|
||
| it("configures refresh from scoped environment material and attaches the runtime provider", async () => { | ||
| process.env.OKTA_CLIENT_ID = "client-id"; | ||
| process.env.OKTA_REFRESH_TOKEN = "refresh-secret"; | ||
| process.env.OKTA_CLIENT_SECRET = "client-secret"; | ||
|
|
||
| await actionApply("default", blueprint({ identity: { okta: oktaIdentity() } })); | ||
|
|
||
| expect(mockExeca).toHaveBeenCalledWith( | ||
| "openshell", | ||
| ["provider", "profile", "import", "--file", "provider-profiles/okta-runtime-v1.yaml"], | ||
| expect.objectContaining({ reject: false }), | ||
| ); | ||
| expect(mockExeca).toHaveBeenCalledWith( | ||
| "openshell", | ||
| [ | ||
| "provider", | ||
| "create", | ||
| "--name", | ||
| "acme-okta-runtime", | ||
| "--type", | ||
| "okta-runtime-v1", | ||
| "--runtime-credentials", | ||
| ], | ||
| expect.objectContaining({ reject: false }), | ||
| ); | ||
| const refreshCall = mockExeca.mock.calls.find( | ||
| (call) => Array.isArray(call[1]) && call[1][0] === "provider" && call[1][2] === "configure", | ||
| ); | ||
| if (!refreshCall) throw new Error("Okta refresh configure call not found"); | ||
| expect(refreshCall[1]).toContain("client_id=client-id"); | ||
| expect(refreshCall[1]).not.toContain("refresh-secret"); | ||
| expect(refreshCall[1]).not.toContain("client-secret"); | ||
| expect(refreshCall[2].env.OKTA_REFRESH_TOKEN).toBe("refresh-secret"); | ||
| expect(refreshCall[2].env.OKTA_CLIENT_SECRET).toBe("client-secret"); | ||
| expect(mockExeca).toHaveBeenCalledWith( | ||
| "openshell", | ||
| ["sandbox", "provider", "attach", "test-sandbox", "acme-okta-runtime"], | ||
| expect.objectContaining({ reject: false }), | ||
| ); | ||
| }); | ||
|
|
||
| it("composes fail-closed middleware while retaining existing middleware", async () => { | ||
| mockExeca.mockImplementation(async (_command: string, args: string[]) => { | ||
| if (args[0] === "policy" && args[1] === "get") { | ||
| return { | ||
| exitCode: 0, | ||
| stdout: [ | ||
| "Version: 1", | ||
| "Hash: sha256:test", | ||
| "---", | ||
| "version: 1", | ||
| "network_policies: {}", | ||
| "network_middlewares:", | ||
| " existing:", | ||
| " middleware: openshell/regex", | ||
| " endpoints:", | ||
| " include: [api.example.com]", | ||
| "", | ||
| ].join("\n"), | ||
| stderr: "", | ||
| }; | ||
| } | ||
| return { exitCode: 0, stdout: "", stderr: "" }; | ||
| }); | ||
| await actionApply( | ||
| "default", | ||
| blueprint({ | ||
| policy: { | ||
| middlewares: { | ||
| identity_guard: { | ||
| middleware: "acme/identity-guard", | ||
| order: 10, | ||
| on_error: "fail_closed", | ||
| endpoints: { include: ["api.example.okta.com"] }, | ||
| }, | ||
| }, | ||
| }, | ||
| }), | ||
| ); | ||
|
|
||
| const merged = [...store.entries()].find(([path]) => path.endsWith("/merged-policy.yaml"))?.[1] | ||
| .content; | ||
| if (!merged) throw new Error("merged policy file not written"); | ||
| expect(YAML.parse(merged).network_middlewares).toMatchObject({ | ||
| existing: { middleware: "openshell/regex" }, | ||
| identity_guard: { | ||
| middleware: "acme/identity-guard", | ||
| order: 10, | ||
| on_error: "fail_closed", | ||
| }, | ||
| }); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.