Skip to content

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

Terminal window
flowcore component add library/iam-validation
Or install the IAM Validation manually

Run this command in your project’s root directory:

Terminal window
bun add ioredis @sinclair/typebox
Terminal window
bunx jsr add @jabr/xxhash64

Create a iam-validation.ts file in your project’s lib folder, and add the following code:

lib/iam-validation.ts
import { ApiException } from "@components/api-exceptions"
import type { FlowcoreAuthenticatedUser } from "@components/jwks-guard"
import type { Logger } from "@components/logger"
import type * as xxHash from "@jabr/xxhash64"
import { type Static, Type } from "@sinclair/typebox"
import type { Redis } from "ioredis"
/**
* 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 }[]
}
export class IamValidateBuilder {
private redisClient: Redis | undefined
private hash!: xxHash.Hasher
private type: "users" | "keys" = "users"
private mode: "tenant" | "organization" = "tenant"
private hashMap: Map<string, boolean> = new Map()
private logger: Logger | undefined
constructor(private readonly iamApiUrl: string) {}
/**
* Sets the Redis client for caching.
*
* @param {Redis} redisClient - The Redis client instance.
* @returns {IamValidateBuilder} - This instance for method chaining.
*/
public withRedisClient(redisClient: Redis): IamValidateBuilder {
this.redisClient = redisClient
return this
}
/**
* 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.hashMap.has(`${id}-${checksum}`)
if (localChecksum) {
const cacheKey = `iam:${this.type}-validation:${checksum}:${id}`
if (this.redisClient) {
const cached = await this.redisClient.get(cacheKey)
if (cached === "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.hashMap.set(`${id}-${data.checksum}`, true)
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

services/request-access.ts
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 library
const 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

my-endpoint.ts
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()
})
}
)