IAM Validation
IAM Validation is a library for validating IAM permissions in your API, and adding flowcore guards to your endpoints.
Install the IAM Validation
flowcore component add library/iam-validation
Or install the IAM Validation manually
Run this command in your project’s root directory:
bun add bun-sqlite-key-value @sinclair/typebox
bunx jsr add @jabr/xxhash64
Create a iam-validation.ts
file in your project’s lib folder, and add the following code:
import { ApiException } from "@components/api-exceptions"import type { Logger } from "@components/logger"import type * as xxHash from "@jabr/xxhash64"import { type Static, Type } from "@sinclair/typebox"import { BunSqliteKeyValue } from "bun-sqlite-key-value"
/** * Represents an API forbidden exception. This exception is thrown when the server refuses to authorize the request. * * @property {number} status - The HTTP status code for the exception, which is 403. * @property {string} code - The error code for the exception, which is "FORBIDDEN". */export class IamForbiddenException extends ApiException { public status = 403 public code = "FORBIDDEN"
constructor( message: string, public readonly validPolicies?: { policyFrn: string; statementId: string }[], public readonly invalidRequest?: RequestedAccess, ) { super(message) }}
/** * Defines the structure of requested access for IAM validation. * * @property {action} - The action or actions to validate against. * @property {resource} - The resource to validate, in order of granularity. First match wins. */export const requestedAccess = Type.Array( Type.Object({ action: Type.Union([Type.String(), Type.Array(Type.String())], { description: "The action or actions to validate against", examples: ["read", "write", "fetch", "ingest"], }), resource: Type.Array(Type.String({ pattern: "^frn::" }), { description: "The resource to validate, in order of granularity. First match wins.", examples: [ [ "frn::tenant", "frn::tenant:data-core/24005dde-7476-4692-aec4-175d2e5a9b9e", "frn::tenant:flow-type/6a394228-e1a1-4491-b280-0117f481be32", "frn::tenant:event-type/ffff7fe3-45c7-4a96-afe2-2519ee342d69", ], [ "frn::13ea0ce0-2f5c-46c4-b4f2-e0e15c5d1cda", "frn::13ea0ce0-2f5c-46c4-b4f2-e0e15c5d1cda:data-core/24005dde-7476-4692-aec4-175d2e5a9b9e", "frn::13ea0ce0-2f5c-46c4-b4f2-e0e15c5d1cda:flow-type/6a394228-e1a1-4491-b280-0117f481be32", "frn::13ea0ce0-2f5c-46c4-b4f2-e0e15c5d1cda:event-type/ffff7fe3-45c7-4a96-afe2-2519ee342d69", ], ], }), }),)
/** * Represents the type of requested access for IAM validation. */export type RequestedAccess = Static<typeof requestedAccess>
/** * Represents the response from IAM validation. * * @property {valid} - Indicates if the validation was successful. * @property {validPolicies} - The policies that were validated. * @property {checksum} - The checksum of the validation request. * @property {invalidRequest} - The invalid request details if validation failed. */export type IamValidationResponse = | { valid: true validPolicies: { policyFrn: string; statementId: string }[] checksum: string } | { valid: false invalidRequest: RequestedAccess validPolicies: { policyFrn: string; statementId: string }[] }
const HASH_KEY_TTL_IN_MS = 300_000
export class IamValidateBuilder { private hash!: xxHash.Hasher private type: "users" | "keys" = "users" private mode: "tenant" | "organization" = "tenant" private logger: Logger | undefined private localCache: BunSqliteKeyValue = new BunSqliteKeyValue(":memory:", { ttlMs: 300_000, })
constructor(private readonly iamApiUrl: string) {}
/** * Sets the hash function for generating checksums. * * @param {xxHash.Hasher} hash - The hash function instance. * @returns {IamValidateBuilder} - This instance for method chaining. */ public withHash(hash: xxHash.Hasher): IamValidateBuilder { this.hash = hash return this }
/** * Sets the type of validation to perform. * * @param {"users" | "keys"} type - The type of validation. * @returns {IamValidateBuilder} - This instance for method chaining. */ public withType(type: "users" | "keys"): IamValidateBuilder { this.type = type return this }
/** * Sets the mode of validation to perform. * * @param {"tenant" | "organization"} mode - The mode of validation. * @returns {IamValidateBuilder} - This instance for method chaining. */ public withMode(mode: "tenant" | "organization"): IamValidateBuilder { this.mode = mode return this }
/** * Sets the logger for logging validation events. * * @param {Logger} logger - The logger instance. * @returns {IamValidateBuilder} - This instance for method chaining. */ public withLogger(logger: Logger): IamValidateBuilder { this.logger = logger return this }
/** * Builds the IAM validation process. * * @param {RequestedAccess} requestedAccess - The requested access details. * @returns {(flowcoreUser: FlowcoreAuthenticatedUser) => Promise<void>} - A promise that resolves to void. */ public build( requestedAccess: RequestedAccess, ): (options: { flowcoreUser: FlowcoreAuthenticatedUser }) => Promise<void> { const checksum = this.hash.hash(JSON.stringify(requestedAccess), "hex").toString() return async ({ flowcoreUser }: { flowcoreUser: FlowcoreAuthenticatedUser }): Promise<void> => { const { id } = flowcoreUser const localChecksum = this.localCache.get<boolean>(`${id}-${checksum}`) if (localChecksum === true) { return }
const validation = await fetch(`${this.iamApiUrl}/api/v1/validate/${this.type}/${id}`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ mode: this.mode, requestedAccess, }), })
const data = (await validation.json()) as IamValidationResponse
if (data.valid) { this.localCache.set(`${id}-${data.checksum}`, true, HASH_KEY_TTL_IN_MS) this.logger?.debug(`IAM validation passed for ${this.type} ${id} with checksum ${data.checksum}`) } else { this.logger?.error("IAM validation failed", data) throw new IamForbiddenException("IAM validation failed", data.validPolicies, data.invalidRequest) } } }}
/** * Enum for defining the types of FRNs. * * @enum {string} */export enum FrnType { USER = "user", KEY = "key", ROLE = "role", POLICY = "policy", ORGANIZATION = "organization", DATA_CORE = "data-core", FLOW_TYPE = "flow-type", EVENT_TYPE = "event-type", ALL = "*",}
/** * Enum for defining the actions that can be performed on FRNs. * * @enum {string} */export enum FrnAction { READ = "read", WRITE = "write", INGEST = "ingest", FETCH = "fetch", ALL = "*",}
/** * Builds a fully qualified FRN string. * * @param {string} tenantOrOrganizationId - The tenant or organization ID. * @param {FrnType} type - The type of FRN. * @param {string} [id] - The ID of the FRN. * @returns {string} - The fully qualified FRN string. */export const buildFrn = (tenantOrOrganizationId: string, type: FrnType, id?: string): string => { return `frn::${tenantOrOrganizationId}:${type}${id ? `/${id}` : ""}`}
/** * Builds a list of fully qualified FRN strings. * * @param {{ type: FrnType; id?: string }[]} items - The list of items to build FRNs for. * @param {string} tenantOrOrganizationId - The tenant or organization ID. * @returns {string[]} - A list of fully qualified FRN strings. */export const buildListFrn = (items: { type: FrnType; id?: string }[], tenantOrOrganizationId: string): string[] => { return [ buildFrn(tenantOrOrganizationId, FrnType.ALL), ...items.map((item) => buildFrn(tenantOrOrganizationId, item.type, item.id)), ]}
Usage
Create a factory for your IAM validation
import env from "@/env"import type { RequestedAccess } from "@components/iam-validation"import { type FrnAction, FrnType, IamValidateBuilder, buildListFrn } from "@components/iam-validation"import type { FlowcoreAuthenticatedUser } from "@components/jwks-guard"import * as xxHash from "@jabr/xxhash64"import { Redis } from "ioredis"
// Utilise your logger of choice here, utilising the flowcore logger libraryconst logger = createLogger("iam-validation")
const hash = await xxHash.create3()const iamValidation = new IamValidateBuilder(env.IAM_API_URL) .withRedisClient(new Redis(env.IAM_REDIS_URL)) .withHash(hash) .withType("users") .withLogger(logger) .withMode("organization")
export class RequestedAccessService { private requestedAccess: RequestedAccess = []
constructor(private readonly flowcoreUser: FlowcoreAuthenticatedUser) {}
/** * Adds a data core access request to the list of requested accesses. * @param {FrnAction | FrnAction[]} action - The action(s) to be performed on the data core. * @param {string} organizationId - The ID of the organization. * @param {string} [id] - The ID of the specific data core. If not provided, defaults to "*" (all data cores). * @returns {this} The current instance for method chaining. */ onDataCore(action: FrnAction | FrnAction[], organizationId: string, id?: string): this { this.requestedAccess.push({ action, resource: buildListFrn([{ type: FrnType.DATA_CORE, id: id ?? "*" }], organizationId), })
return this }
// add `on<resource>` methods for other resources
/** * Evaluates the requested accesses against the user's permissions. * @returns {Promise<void>} A promise that resolves to true if the user has all requested permissions, false otherwise. */ evaluate(): Promise<void> { return iamValidation.build(this.requestedAccess)({ flowcoreUser: this.flowcoreUser }) }}
For your endpoints, add the following the beforeHandler in your subsequent endpoints
export const myEndpoint = new Elysia() .get( "/", () => "Hello World", { beforeHandler: ({ flowcoreUser, params }) => { return new RequestedAccessService(flowcoreUser) .onDataCore(FrnAction.READ, params.tenant) .evaluate() }, params: t.Object({ tenant: t.String() }) } )