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-validationOr install the IAM Validation manually
Run this command in your project’s root directory:
bun add bun-sqlite-key-value @sinclair/typeboxbunx jsr add @jabr/xxhash64Create 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()      })    }  )