diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1e81a60..fb31eb9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,7 +23,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v3 with: - node-version: "18" # Specify your Node.js version here + node-version: "20" # testcontainers (FGA integration tests) needs Node >= 20 (global File) - name: Set up pnpm run: | diff --git a/.gitignore b/.gitignore index 62f17cf..9d17e88 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,6 @@ package-lock.json .history .env .env.* -.pnpm-store \ No newline at end of file +.pnpm-store +# Local planning artifacts (not for publication) +docs/superpowers/ diff --git a/README.md b/README.md index 5510d39..8b78eb5 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,66 @@ async function main() { } ``` +## Fine-grained authorization (FGA) + +Authorizer ships an embedded [OpenFGA](https://openfga.dev) engine for relationship-based +access control (ReBAC). You model your domain as object **types** with **relations** +(`viewer`, `editor`, `owner`…), grant access by writing **relationship tuples** +(`user:alice` is `viewer` of `document:1`), and ask the engine whether access is allowed. + +Authoring the model and tuples is an admin task — do it once in the dashboard under +**Authorization**, or via the `_fga_*` admin GraphQL API. The SDK exposes only the +read-side checks an application needs at request time. For every call the subject +defaults to the authenticated caller and is pinned server-side from the request +(session cookie by default; pass the authorization header in node.js). The optional +`user` field (`"type:id"`, or a bare id treated as `"user:"`) lets you check on +behalf of someone else, but the server honors it only for super-admin callers or when +it equals the caller's own token subject — anything else is rejected, never silently +ignored. + +**1. Check permissions** — `checkPermissions` answers "does the subject have +`relation` on `object`?" for one or more pairs in a single round trip. `results` +come back in the same order as the supplied `checks`, each echoing its +relation/object pair. + +```js +const { data } = await authRef.checkPermissions( + { checks: [{ relation: 'can_view', object: 'document:1' }] }, + { Authorization: `Bearer ${token}` }, // omit in the browser to use the cookie +); + +if (data?.results?.[0]?.allowed) { + // caller may view document:1 +} +``` + +Batch several checks at once: + +```js +const { data } = await authRef.checkPermissions({ + checks: [ + { relation: 'can_view', object: 'document:1' }, + { relation: 'can_edit', object: 'document:1' }, + ], +}); +// data?.results => +// [ +// { relation: 'can_view', object: 'document:1', allowed: true }, +// { relation: 'can_edit', object: 'document:1', allowed: false }, +// ] +``` + +**2. List accessible objects** — `listPermissions` returns the ids of every object of +a type the subject relates to (handy for filtering a list to what the user can see). + +```js +const { data } = await authRef.listPermissions({ + relation: 'can_view', + object_type: 'document', +}); +// data?.objects => ['document:1', 'document:7', ...] +``` + ## Local Development Setup ### Prerequisites diff --git a/__test__/index.test.ts b/__test__/index.test.ts index b5d0daf..4ff6296 100644 --- a/__test__/index.test.ts +++ b/__test__/index.test.ts @@ -80,7 +80,11 @@ describe('Integration Tests - authorizer-js', () => { beforeAll(async () => { const { args, clientId } = buildAuthorizerCliArgs(); - container = await new GenericContainer('lakhansamani/authorizer:2.0.0-rc.6') + // Override with AUTHORIZER_IMAGE to test against a different server build + // (e.g. a locally built image with newer GraphQL surface). + container = await new GenericContainer( + process.env.AUTHORIZER_IMAGE || 'lakhansamani/authorizer:2.0.0-rc.6', + ) .withCommand(args) .withExposedPorts(8080) .withWaitStrategy(Wait.forHttp('/health', 8080).forStatusCode(200)) @@ -100,7 +104,13 @@ describe('Integration Tests - authorizer-js', () => { )}/app`; authorizerConfig.clientID = clientId; console.log('Authorizer URL:', authorizerConfig.authorizerURL); - authorizer = new Authorizer(authorizerConfig); + authorizer = new Authorizer({ + ...authorizerConfig, + // Node sends no implicit Origin header; newer server builds enforce + // CSRF on state-changing requests (Origin must match the server host). + // Browsers set this automatically, so this only affects node tests. + extraHeaders: { Origin: authorizerConfig.authorizerURL }, + }); }); afterAll(async () => { @@ -181,6 +191,193 @@ describe('Integration Tests - authorizer-js', () => { expect(validateRes?.data?.is_valid).toEqual(true); }); + // ---- Fine-grained authorization (FGA) ---- + // + // The embedded OpenFGA engine auto-enables when the main database is + // SQL-compatible (this container runs sqlite), so the permission-check + // surface is live out of the box on FGA-capable servers. Older server + // images predate the check_permissions / list_permissions GraphQL fields + // entirely; the probe in the setup test detects that and the FGA assertions + // no-op with a warning instead of failing — they light up automatically + // once AUTHORIZER_IMAGE points at an FGA-capable build. + // + // Model/tuple authoring is an admin concern and deliberately NOT part of + // the SDK surface; the setup below uses the raw `graphqlQuery` escape hatch + // with the admin secret, mirroring how the dashboard drives the `_fga_*` + // admin API. + let fgaSupported = false; + + const fgaModelDsl = `model + schema 1.1 +type user +type document + relations + define viewer: [user] + define can_view: viewer +`; + + const fgaSkipWarning = () => + console.warn( + 'Skipping FGA assertions: server image has no check_permissions GraphQL surface. Set AUTHORIZER_IMAGE to an FGA-capable build to run them.', + ); + + it('should install an FGA model and grant a tuple (admin setup)', async () => { + expect(loginRes?.data?.access_token).toBeDefined(); + expect(loginRes?.data?.access_token).not.toBeNull(); + + // Probe: a server without FGA fails GraphQL validation on the + // check_permissions field ("Cannot query field"); any other outcome (data + // or an engine / auth error) proves the surface exists. + const probe = await authorizer.graphqlQuery({ + query: + 'query fgaProbe { check_permissions(params: { checks: [{ relation: "viewer", object: "document:probe" }] }) { results { allowed } } }', + headers: { Authorization: `Bearer ${loginRes?.data?.access_token}` }, + operationName: 'fgaProbe', + }); + fgaSupported = !probe?.errors?.some((e) => + e?.message?.includes('Cannot query field'), + ); + if (!fgaSupported) { + fgaSkipWarning(); + return; + } + + const adminHeaders = { + 'x-authorizer-admin-secret': authorizerConfig.adminSecret, + }; + + // Install a minimal model: viewer is granted directly, can_view derives + // from it. + const modelRes = await authorizer.graphqlQuery({ + query: + 'mutation fgaWriteModel($params: FgaWriteModelInput!) { _fga_write_model(params: $params) { id dsl } }', + variables: { params: { dsl: fgaModelDsl } }, + headers: adminHeaders, + operationName: 'fgaWriteModel', + }); + expect(modelRes?.errors).toHaveLength(0); + expect(modelRes?.data?._fga_write_model?.id).toBeDefined(); + + // The runtime checks pin the subject to the caller's token sub (the user + // id), so the granted tuple must reference it. + const profileRes = await authorizer.getProfile({ + Authorization: `Bearer ${loginRes?.data?.access_token}`, + }); + expect(profileRes?.errors).toHaveLength(0); + testConfig.userId = profileRes?.data?.id || ''; + expect(testConfig.userId.length).toBeGreaterThan(0); + + const tuplesRes = await authorizer.graphqlQuery({ + query: + 'mutation fgaWriteTuples($params: FgaWriteTuplesInput!) { _fga_write_tuples(params: $params) { message } }', + variables: { + params: { + tuples: [ + { + user: `user:${testConfig.userId}`, + relation: 'viewer', + object: 'document:fga-doc-1', + }, + ], + }, + }, + headers: adminHeaders, + operationName: 'fgaWriteTuples', + }); + expect(tuplesRes?.errors).toHaveLength(0); + }); + + it('should allow checkPermissions for a granted relation and deny otherwise', async () => { + if (!fgaSupported) return fgaSkipWarning(); + const authHeaders = { + Authorization: `Bearer ${loginRes?.data?.access_token}`, + }; + + // can_view derives from the granted viewer tuple. + const allowedRes = await authorizer.checkPermissions( + { checks: [{ relation: 'can_view', object: 'document:fga-doc-1' }] }, + authHeaders, + ); + expect(allowedRes?.errors).toHaveLength(0); + expect(allowedRes?.data?.results).toHaveLength(1); + expect(allowedRes?.data?.results?.[0]).toEqual({ + relation: 'can_view', + object: 'document:fga-doc-1', + allowed: true, + }); + + // Nothing grants doc-2 — a clean deny (allowed=false), not an error. + const deniedRes = await authorizer.checkPermissions( + { checks: [{ relation: 'can_view', object: 'document:fga-doc-2' }] }, + authHeaders, + ); + expect(deniedRes?.errors).toHaveLength(0); + expect(deniedRes?.data?.results).toHaveLength(1); + expect(deniedRes?.data?.results?.[0]?.allowed).toEqual(false); + }); + + it('should honor contextual tuples in checkPermissions', async () => { + if (!fgaSupported) return fgaSkipWarning(); + // The contextual tuple grants viewer on doc-2 for this single evaluation + // only; nothing is persisted. + const res = await authorizer.checkPermissions( + { + checks: [ + { + relation: 'can_view', + object: 'document:fga-doc-2', + contextual_tuples: [ + { + user: `user:${testConfig.userId}`, + relation: 'viewer', + object: 'document:fga-doc-2', + }, + ], + }, + ], + }, + { Authorization: `Bearer ${loginRes?.data?.access_token}` }, + ); + expect(res?.errors).toHaveLength(0); + expect(res?.data?.results?.[0]?.allowed).toEqual(true); + }); + + it('should return positional results from a batched checkPermissions', async () => { + if (!fgaSupported) return fgaSkipWarning(); + const res = await authorizer.checkPermissions( + { + checks: [ + { relation: 'can_view', object: 'document:fga-doc-1' }, + { relation: 'can_view', object: 'document:fga-doc-2' }, + ], + }, + { Authorization: `Bearer ${loginRes?.data?.access_token}` }, + ); + expect(res?.errors).toHaveLength(0); + expect(res?.data?.results).toHaveLength(2); + // Results are positional and echo the checked pair. + expect(res?.data?.results?.[0]).toEqual({ + relation: 'can_view', + object: 'document:fga-doc-1', + allowed: true, + }); + expect(res?.data?.results?.[1]).toEqual({ + relation: 'can_view', + object: 'document:fga-doc-2', + allowed: false, + }); + }); + + it('should list accessible objects via listPermissions', async () => { + if (!fgaSupported) return fgaSkipWarning(); + const res = await authorizer.listPermissions( + { relation: 'can_view', object_type: 'document' }, + { Authorization: `Bearer ${loginRes?.data?.access_token}` }, + ); + expect(res?.errors).toHaveLength(0); + expect(res?.data?.objects).toEqual(['document:fga-doc-1']); + }); + it('should update profile successfully', async () => { expect(loginRes?.data?.access_token).toBeDefined(); expect(loginRes?.data?.access_token).not.toBeNull(); diff --git a/src/index.ts b/src/index.ts index 5475d4b..9303389 100644 --- a/src/index.ts +++ b/src/index.ts @@ -245,6 +245,64 @@ export class Authorizer { } }; + // checkPermissions evaluates one or more permission checks ("does the + // subject have `relation` on `object`?") in a single round trip using the + // embedded OpenFGA engine. Results come back in the same order as the + // supplied `checks`, each echoing its relation/object pair. + // + // The subject defaults to the authenticated caller and is pinned server-side + // from the request (session cookie by default; pass the authorization header + // in node.js). The optional `params.user` ("type:id", or a bare id treated + // as "user:") is honored only for super-admin callers or when it equals + // the caller's own token subject; anything else is rejected by the server. + checkPermissions = async ( + params: Types.CheckPermissionsInput, + headers?: Types.Headers, + ): Promise> => { + try { + const res = await this.graphqlQuery({ + query: + 'query checkPermissions($params: CheckPermissionsInput!){ check_permissions(params: $params) { results { relation object allowed } } }', + headers, + variables: { params }, + operationName: 'checkPermissions', + }); + + return res?.errors?.length + ? this.errorResponse(res.errors) + : this.okResponse(res.data?.check_permissions); + } catch (error) { + return this.errorResponse([error]); + } + }; + + // listPermissions returns the fully-qualified ids of objects of + // `object_type` the subject holds `relation` on (handy for filtering a list + // to what the user can see). Subject resolution follows the same rules as + // checkPermissions: it defaults to the authenticated caller, and the + // optional `params.user` override is honored only for super-admin callers + // or when it equals the caller's own token subject. + listPermissions = async ( + params: Types.ListPermissionsInput, + headers?: Types.Headers, + ): Promise> => { + try { + const res = await this.graphqlQuery({ + query: + 'query listPermissions($params: ListPermissionsInput!){ list_permissions(params: $params) { objects } }', + headers, + variables: { params }, + operationName: 'listPermissions', + }); + + return res?.errors?.length + ? this.errorResponse(res.errors) + : this.okResponse(res.data?.list_permissions); + } catch (error) { + return this.errorResponse([error]); + } + }; + // this is used to verify / get session using cookie by default. If using node.js pass authorization header getSession = async ( headers?: Types.Headers, diff --git a/src/types.ts b/src/types.ts index 3336ff2..1087450 100644 --- a/src/types.ts +++ b/src/types.ts @@ -285,6 +285,76 @@ export interface DeleteUserRequest { email: string; } +// Fine-grained authorization (FGA) types — the client-facing surface of +// Authorizer's embedded OpenFGA engine. Only the read-side operations a relying +// party needs are exposed: checking access and listing accessible objects. +// Authoring the authorization model and relationship tuples is an admin concern +// handled from the dashboard / `_fga_*` admin API, and is not part of this SDK. +// +// For every operation the subject defaults to the authenticated caller and is +// pinned server-side from the request (session cookie or bearer token). The +// optional `user` override ("type:id", or a bare id treated as "user:") +// is honored only when the caller is a super-admin or when it equals the +// caller's own token subject; anything else is rejected by the server. + +// FgaTupleInput is a single relationship tuple (user is related to object via +// relation), used to pass contextual tuples evaluated for one check only and +// never persisted. +export interface FgaTupleInput { + user: string; + relation: string; + object: string; +} + +// PermissionCheckInput is one permission to evaluate: "does the subject have +// `relation` on `object`?". Contextual tuples are evaluated for this check +// only and never persisted. +export interface PermissionCheckInput { + relation: string; + object: string; + contextual_tuples?: FgaTupleInput[] | null; +} + +// CheckPermissionsInput evaluates one or more permission checks in a single +// call. The subject defaults to the authenticated caller (JWT / session +// cookie). The optional `user` ("type:id", or a bare id treated as +// "user:") is honored only when the caller is a super-admin OR it equals +// the caller's own token subject; anything else is rejected by the server — +// never silently ignored. +export interface CheckPermissionsInput { + checks: PermissionCheckInput[]; + user?: string | null; +} + +// PermissionCheckResult is the outcome of one permission check, echoing the +// checked pair so batch results are self-describing (and positionally aligned +// with the supplied `checks`). +export interface PermissionCheckResult { + relation: string; + object: string; + allowed: boolean; +} + +// CheckPermissionsResponse carries one result per supplied check, in order. +export interface CheckPermissionsResponse { + results: PermissionCheckResult[]; +} + +// ListPermissionsInput enumerates the objects of `object_type` the subject +// holds `relation` on. Subject resolution (the optional `user` override) +// follows the same rules as CheckPermissionsInput.user. +export interface ListPermissionsInput { + relation: string; + object_type: string; + user?: string | null; +} + +// ListPermissionsResponse lists fully-qualified object ids (e.g. "document:1") +// the subject holds the queried permission on. +export interface ListPermissionsResponse { + objects: string[]; +} + // SessionQueryRequest export interface SessionQueryRequest { roles?: string[] | null;