Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10,825 changes: 10,825 additions & 0 deletions dist/index.cjs

Large diffs are not rendered by default.

9,738 changes: 9,738 additions & 0 deletions dist/index.d.cts

Large diffs are not rendered by default.

9,738 changes: 9,738 additions & 0 deletions dist/index.d.ts

Large diffs are not rendered by default.

10,532 changes: 10,532 additions & 0 deletions dist/index.js

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions src/commands/iam/entitlements/resolve-key-entitlements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Command } from "../../../common/command.ts"
import { parseResponseHelper } from "../../../utils/parse-response-helper.ts"
import {
type EntitlementRequestAccessItem,
type EntitlementResponse,
EntitlementResponseSchema,
} from "./resolve-user-entitlements.ts"

export interface ResolveKeyEntitlementsInput {
keyId: string
mode: "tenant" | "organization"
requestedAccess: EntitlementRequestAccessItem[]
}

export class ResolveKeyEntitlementsCommand extends Command<ResolveKeyEntitlementsInput, EntitlementResponse> {
protected override retryOnFailure = true
protected override getMethod(): string { return "POST" }
protected override getBaseUrl(): string { return "https://iam.api.flowcore.io" }
protected override getPath(): string { return `/api/v1/entitlements/keys/${this.input.keyId}` }
protected override getBody(): Record<string, unknown> {
return { mode: this.input.mode, requestedAccess: this.input.requestedAccess }
}
protected override parseResponse(rawResponse: unknown): EntitlementResponse {
return parseResponseHelper(EntitlementResponseSchema, rawResponse)
}
}
54 changes: 54 additions & 0 deletions src/commands/iam/entitlements/resolve-user-entitlements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Command } from "../../../common/command.ts"
import { parseResponseHelper } from "../../../utils/parse-response-helper.ts"
import { PolicyFilterSchema } from "../policy-filters.ts"
import { type Static, Type } from "@sinclair/typebox"

export interface EntitlementRequestAccessItem {
action: string | string[]
resource: string[]
}

export const EntitlementResponseSchema = Type.Object({
valid: Type.Literal(true),
requestChecksum: Type.String(),
entitlementChecksum: Type.String(),
cacheTtlSeconds: Type.Number(),
entitlements: Type.Array(
Type.Object({
requestIndex: Type.Number(),
grants: Type.Array(
Type.Object({
policyFrn: Type.String(),
statementId: Type.String(),
resource: Type.String(),
filters: Type.Optional(Type.Array(PolicyFilterSchema)),
}),
),
}),
),
})

export type EntitlementResponse = Static<typeof EntitlementResponseSchema>

export function parseEntitlementResponse(rawResponse: unknown): EntitlementResponse {
return parseResponseHelper(EntitlementResponseSchema, rawResponse)
}

export interface ResolveUserEntitlementsInput {
userId: string
mode: "tenant" | "organization"
requestedAccess: EntitlementRequestAccessItem[]
}

export class ResolveUserEntitlementsCommand extends Command<ResolveUserEntitlementsInput, EntitlementResponse> {
protected override retryOnFailure = true
protected override getMethod(): string { return "POST" }
protected override getBaseUrl(): string { return "https://iam.api.flowcore.io" }
protected override getPath(): string { return `/api/v1/entitlements/users/${this.input.userId}` }
protected override getBody(): Record<string, unknown> {
return { mode: this.input.mode, requestedAccess: this.input.requestedAccess }
}
protected override parseResponse(rawResponse: unknown): EntitlementResponse {
return parseEntitlementResponse(rawResponse)
}
}
5 changes: 5 additions & 0 deletions src/commands/iam/policies/create-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type TUnion,
Type,
} from "@sinclair/typebox"
import { type PolicyFilter, PolicyFilterSchema } from "../policy-filters.ts"

/**
* The schema for a policy statement document
Expand All @@ -19,10 +20,12 @@ export const PolicyStatementSchema: TObject<{
statementId: TOptional<TString>
resource: TString
action: TUnion<[TString, TArray<TString>]>
filters: TOptional<TArray<typeof PolicyFilterSchema>>
}> = Type.Object({
statementId: Type.Optional(Type.String()),
resource: Type.String(),
action: Type.Union([Type.String(), Type.Array(Type.String())]),
filters: Type.Optional(Type.Array(PolicyFilterSchema, { minItems: 1, maxItems: 10 })),
})

/**
Expand Down Expand Up @@ -80,6 +83,8 @@ export interface PolicyCreateInput {
resource: string
/** The actions for this statement */
action: string | string[]
/** Optional plaintext event payload filters. */
filters?: PolicyFilter[]
}>
/** The description of the policy */
description?: string
Expand Down
4 changes: 4 additions & 0 deletions src/commands/iam/policies/id/update-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ export class PolicyUpdateCommand extends Command<PolicyUpdateInput, Policy> {
}
}

protected override getHeaders(): Record<string, string> {
return { ...super.getHeaders(), "x-flowcore-policy-conditions": "1" }
}

/**
* Parse the response
*/
Expand Down
3 changes: 3 additions & 0 deletions src/commands/iam/policies/validate-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { Command } from "../../../common/command.ts"
import { parseResponseHelper } from "../../../utils/parse-response-helper.ts"
import { type Static, type TLiteral, type TObject, Type } from "@sinclair/typebox"
import type { PolicyFilter } from "../policy-filters.ts"

/**
* The schema for a successful policy validation response.
Expand Down Expand Up @@ -43,6 +44,8 @@ export interface PolicyValidateInput {
resource: string
/** The actions for this statement */
action: string | string[]
/** Optional plaintext event payload filters. */
filters?: PolicyFilter[]
}>
/** The optional principal role FRN (cross-tenant is intentionally permitted here) */
principal?: string
Expand Down
67 changes: 67 additions & 0 deletions src/commands/iam/policy-filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { type Static, Type } from "@sinclair/typebox"

export const PolicyFilterValueSchema = Type.Union([
Type.String({ maxLength: 512 }),
Type.Number(),
Type.Boolean(),
Type.Null(),
])

const PolicyFilterPathSchema = Type.String({
minLength: 1,
maxLength: 256,
pattern: "^[A-Za-z0-9_-]+(?:\\.[A-Za-z0-9_-]+){0,7}$",
})

export const PolicyFilterSchema = Type.Union([
Type.Object({ path: PolicyFilterPathSchema, operator: Type.Literal("equals"), value: PolicyFilterValueSchema }, { additionalProperties: false }),
Type.Object({ path: PolicyFilterPathSchema, operator: Type.Literal("oneOf"), values: Type.Array(PolicyFilterValueSchema, { minItems: 1, maxItems: 20 }) }, { additionalProperties: false }),
Type.Object({ path: PolicyFilterPathSchema, operator: Type.Literal("exists"), value: Type.Boolean() }, { additionalProperties: false }),
])

export type PolicyFilterValue = Static<typeof PolicyFilterValueSchema>
export type PolicyFilter = Static<typeof PolicyFilterSchema>

const RESERVED_PATH_SEGMENTS = new Set(["__proto__", "prototype", "constructor"])

function ownPath(payload: unknown, path: string): { exists: boolean; value?: unknown } {
let current = payload
for (const segment of path.split(".")) {
if (RESERVED_PATH_SEGMENTS.has(segment) || typeof current !== "object" || current === null || Array.isArray(current)) {
return { exists: false }
}
if (!Object.hasOwn(current, segment)) return { exists: false }
current = (current as Record<string, unknown>)[segment]
}
return { exists: true, value: current }
}

export function isEncryptedPayload(payload: unknown): boolean {
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return false
if (!Object.hasOwn(payload, "encryptedPayload")) return false
const keys = Object.keys(payload)
const value = (payload as Record<string, unknown>).encryptedPayload
return keys.length === 1 && typeof value === "string" && value.split(".").length === 3
}

export function matchesPolicyFilters(filters: readonly PolicyFilter[], payload: unknown): boolean {
if (isEncryptedPayload(payload)) return false
return filters.every((filter) => {
const resolved = ownPath(payload, filter.path)
if (filter.operator === "exists") return resolved.exists === filter.value
if (!resolved.exists) return false
if (filter.operator === "equals") return resolved.value === filter.value
return filter.values.some((value) => resolved.value === value)
})
}

export type EntitlementGrant = {
policyFrn: string
statementId: string
resource: string
filters?: PolicyFilter[]
}

export function permitsPayload(grants: readonly EntitlementGrant[], payload: unknown): boolean {
return grants.some((grant) => grant.filters === undefined || matchesPolicyFilters(grant.filters, payload))
}
3 changes: 3 additions & 0 deletions src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ export * from "./iam/tenant-iam-audit/get-audit-for-tenant.ts"

export * from "./iam/validate/validate-key.ts"
export * from "./iam/validate/validate-user.ts"
export * from "./iam/policy-filters.ts"
export * from "./iam/entitlements/resolve-user-entitlements.ts"
export * from "./iam/entitlements/resolve-key-entitlements.ts"

// Compute
export * from "./compute/compute-domain.attach.ts"
Expand Down
2 changes: 2 additions & 0 deletions test/tests/commands/iam/policies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ describe("Policy commands", () => {
.matchHeaders({
"authorization": "Bearer BEARER_TOKEN",
"content-type": "application/json",
"x-flowcore-policy-conditions": "1",
})
.respondWith(200, mockResponse)

Expand Down Expand Up @@ -439,6 +440,7 @@ describe("Policy commands", () => {
.matchHeaders({
"authorization": "Bearer BEARER_TOKEN",
"content-type": "application/json",
"x-flowcore-policy-conditions": "1",
})
.respondWith(403, { error: "Insufficient permissions" })

Expand Down
37 changes: 37 additions & 0 deletions test/tests/commands/policy-filters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { isEncryptedPayload, matchesPolicyFilters, permitsPayload } from "../../../src/commands/iam/policy-filters.ts"
import { describe, expect, it } from "bun:test"

describe("policy payload filters", () => {
it("uses strict own-property comparisons", () => {
expect(matchesPolicyFilters([{ path: "customer.region", operator: "equals", value: "EU" }], { customer: { region: "EU" } })).toBe(true)
expect(matchesPolicyFilters([{ path: "count", operator: "equals", value: 1 }], { count: "1" })).toBe(false)
expect(matchesPolicyFilters([{ path: "missing", operator: "exists", value: false }], { missing: null })).toBe(false)
expect(matchesPolicyFilters([{ path: "missing", operator: "exists", value: false }], {})).toBe(true)
})

it("supports oneOf and ANDs filters within a grant", () => {
expect(matchesPolicyFilters([
{ path: "region", operator: "oneOf", values: ["FO", "DK"] },
{ path: "active", operator: "equals", value: true },
], { region: "FO", active: true })).toBe(true)
})

it("blocks reserved traversal segments", () => {
expect(matchesPolicyFilters([{ path: "constructor.name", operator: "equals", value: "Object" }], {})).toBe(false)
expect(matchesPolicyFilters([{ path: "items.0", operator: "equals", value: "x" }], { items: ["x"] })).toBe(false)
})

it("recognizes only the canonical encrypted envelope", () => {
expect(isEncryptedPayload({ encryptedPayload: "a.b.c" })).toBe(true)
expect(isEncryptedPayload({ encryptedPayload: "a.b.c", visible: true })).toBe(false)
expect(matchesPolicyFilters([{ path: "encryptedPayload", operator: "exists", value: true }], { encryptedPayload: "a.b.c" })).toBe(false)
})

it("ORs grants and lets an unconditional grant dominate", () => {
const grants = [
{ policyFrn: "p1", statementId: "s1", resource: "r", filters: [{ path: "region", operator: "equals" as const, value: "FO" }] },
{ policyFrn: "p2", statementId: "s2", resource: "r" },
]
expect(permitsPayload(grants, { region: "US" })).toBe(true)
})
})
Loading