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
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ package-lock.json
.history
.env
.env.*
.pnpm-store
.pnpm-store
# Local planning artifacts (not for publication)
docs/superpowers/
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>"`) 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
Expand Down
201 changes: 199 additions & 2 deletions __test__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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();
Expand Down
58 changes: 58 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>") 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<Types.ApiResponse<Types.CheckPermissionsResponse>> => {
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<Types.ApiResponse<Types.ListPermissionsResponse>> => {
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,
Expand Down
Loading
Loading