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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ jobs:
--chain.chainId 1717658228 \
--wallet.seed "spoon ostrich survey tumble tube used person also wasp rack cabbage liberty" &
working-directory: ./pkg/ethereum
- name: 🤖 Start cerbos
run: |
pnpm exec cerbos server --config=./test/.cerbos.yaml &
working-directory: ./pkg/cerbos
- name: 🧪 Run the tests
run: pnpm coverage
- name: 🧪 Test grammar package
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -97,5 +97,10 @@ pkg/oauth/test/didroom_microservices/
!/pkg/oauth/test/credential_issuer.keys.json
!/pkg/oauth/test/relying_party.keys.json

# cerbos test stuff
!/pkg/cerbos/test/.cerbos.yaml
!/pkg/cerbos/test/policies/document.yaml
!/pkg/cerbos/test/policies/_schemas/document.json

# mise stuff
!/mise.toml
2 changes: 2 additions & 0 deletions docs/statements/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {markdownTable} from "markdown-table";
// slangroom
import {Slangroom} from "@slangroom/core";
// packages
import {cerbos} from "@slangroom/cerbos";
import {db} from "@slangroom/db";
import {dcql} from "@slangroom/dcql";
import {did} from "@slangroom/did";
Expand Down Expand Up @@ -58,6 +59,7 @@ const generateTable = (plugin, name) => {
}

[
[cerbos, 'cerbos'],
[db, 'db'],
[dcql, 'dcql'],
[did, 'did'],
Expand Down
1 change: 1 addition & 0 deletions docs/statements/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"@slangroom/cerbos": "workspace:^",
"@slangroom/core": "workspace:^",
"@slangroom/db": "workspace:^",
"@slangroom/dcql": "workspace:^",
Expand Down
19 changes: 19 additions & 0 deletions examples/cerbos/evaluate_access.data.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"principal": {
"id": "user@example.com",
"roles": [
"user"
],
"attr": {
"tier": "PREMIUM"
}
},
"resource": {
"kind": "document",
"id": "1",
"attr": {
"owner": "user@example.com"
}
},
"action": "view"
}
3 changes: 3 additions & 0 deletions examples/cerbos/evaluate_access.keys.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"cerbos": "http://localhost:3592"
}
4 changes: 4 additions & 0 deletions examples/cerbos/evaluate_access.meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"title": "evaluate access for principal to perform action on resource",
"highlight": "1"
}
4 changes: 4 additions & 0 deletions examples/cerbos/evaluate_access.slang
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Prepare 'res': connect to 'cerbos' and evaluate access with principal 'principal', resource 'resource', action 'action'

Given I have a 'boolean' named 'res'
Then print data
40 changes: 40 additions & 0 deletions pkg/cerbos/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "@slangroom/cerbos",
"version": "1.51.0",
"dependencies": {
"@cerbos/http": "0.23.4",
"@slangroom/core": "workspace:*",
"@slangroom/shared": "workspace:*",
"zod": "4.1.13"
},
"repository": "https://github.com/dyne/slangroom",
"license": "AGPL-3.0-only",
"type": "module",
"main": "./build/esm/src/index.js",
"types": "./build/esm/src/index.d.ts",
"exports": {
".": {
"import": {
"types": "./build/esm/src/index.d.ts",
"default": "./build/esm/src/index.js"
}
},
"./*": {
"import": {
"types": "./build/esm/src/*.d.ts",
"default": "./build/esm/src/*.js"
}
},
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public"
},
"engines": {
"node": "^18.20.0 || ^20.10.0 || >=22"
},
"devDependencies": {
"cerbos": "^0.47.0",
"cerbosctl": "^0.47.0"
}
}
3 changes: 3 additions & 0 deletions pkg/cerbos/package.json.license
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
SPDX-FileCopyrightText: 2024 Dyne.org foundation

SPDX-License-Identifier: AGPL-3.0-or-later
5 changes: 5 additions & 0 deletions pkg/cerbos/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// SPDX-FileCopyrightText: 2024 Dyne.org foundation
//
// SPDX-License-Identifier: AGPL-3.0-or-later

export * from '@slangroom/cerbos/plugin';
53 changes: 53 additions & 0 deletions pkg/cerbos/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2024 Dyne.org foundation
//
// SPDX-License-Identifier: AGPL-3.0-or-later

import { Plugin } from '@slangroom/core';
import { HTTP } from "@cerbos/http";
// read the version from the package.json
import packageJson from '@slangroom/cerbos/package.json' with { type: 'json' };
import { actionSchema, principalSchema, resourceSchema } from './types.js';

export const version = packageJson.version;

export class CerbosError extends Error {
constructor(message: string) {
super(message);
this.name = 'Slangroom @slangroom/cerbos@' + packageJson.version + ' Error';
}
};

const p = new Plugin();

/**
* @internal
*/
export const allowed = p.new('connect',
['principal', 'resource', 'action'],
'evaluate access',
async (ctx) => {
const cerbosUrl = ctx.fetchConnect()[0];
const { success: principalIsValid, data: principal } = principalSchema.safeParse(ctx.fetch("principal"));
if (!principalIsValid)
return ctx.fail(new CerbosError("Principal is not valid"));
const { success: resourceIsValid, data: resource } = resourceSchema.safeParse(ctx.fetch("resource"));
if (!resourceIsValid)
return ctx.fail(new CerbosError("Resource is not valid"));
const { success: actionIsValid, data: action } = actionSchema.safeParse(ctx.fetch("action"));
if (!actionIsValid)
return ctx.fail(new CerbosError("Action is not valid"));
try {
const cerbos = new HTTP(cerbosUrl);
const result = await cerbos.isAllowed({
principal,
resource,
action,
});
return ctx.pass(result);
} catch (e) {
return ctx.fail(new CerbosError(e.message));
}
}
)

export const cerbos = p;
44 changes: 44 additions & 0 deletions pkg/cerbos/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: 2025 Dyne.org foundation
//
// SPDX-License-Identifier: AGPL-3.0-or-later

import { z } from 'zod';

type Value =
| string
| number
| boolean
| null
| { [key: string]: Value }
| Value[];

const valueSchema: z.ZodType<Value> = z.lazy(() =>
z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
z.array(valueSchema),
z.record(z.string(), valueSchema),
])
);

export const principalSchema = z.object({
id: z.string(),
roles: z.array(z.string()),
attr: z.record(z.string(), valueSchema).optional(),
attributes: z.record(z.string(), valueSchema).optional(), // deprecated, prefer attr
policyVersion: z.string().optional(),
scope: z.string().optional(),
});

export const resourceSchema = z.object({
kind: z.string(),
id: z.string(),
attr: z.record(z.string(), valueSchema).optional(),
attributes: z.record(z.string(), valueSchema).optional(), // deprecated, prefer attr
policyVersion: z.string().optional(),
scope: z.string().optional(),
});

export const actionSchema = z.string();
14 changes: 14 additions & 0 deletions pkg/cerbos/test/.cerbos.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: 2024 Dyne.org foundation
#
# SPDX-License-Identifier: AGPL-3.0-or-later

engine:
defaultPolicyVersion: "default"
server:
httpListenAddr: ":3592"
storage:
driver: "disk"
disk:
directory: test/policies
schema:
enforcement: reject
138 changes: 138 additions & 0 deletions pkg/cerbos/test/e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// SPDX-FileCopyrightText: 2024 Dyne.org foundation
//
// SPDX-License-Identifier: AGPL-3.0-or-later

import test from 'ava';
import { Slangroom } from '@slangroom/core';
import { cerbos } from '@slangroom/cerbos';
import packageJson from '@slangroom/cerbos/package.json' with { type: 'json' };

const stripAnsiCodes = (str: string) => str.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '').replace(/[ \t]+(?=\r?\n|$)/g, '');

const script = `
Prepare 'res': connect to 'cerbos' and evaluate access with principal 'principal', resource 'resource', action 'action'

Given I have a 'boolean' named 'res'
Then print data
`;

test('Valid access', async (t) => {
const slangroom = new Slangroom(cerbos);
const res = await slangroom.execute(script, {
data: {
cerbos: "http://localhost:3592",
principal: {
id: "user@example.com",
roles: ["user"],
attr: { tier: "PREMIUM" },
},
resource: {
kind: "document",
id: "1",
attr: { owner: "user@example.com" },
},
action: "view",
},
});
t.deepEqual(res.result, { res: true }, JSON.stringify(res, null, 2));
});

test('Invalid access [tier not met]', async (t) => {
const slangroom = new Slangroom(cerbos);
const res = await slangroom.execute(script, {
data: {
cerbos: "http://localhost:3592",
principal: {
id: "user@example.com",
roles: ["user"],
attr: { tier: "GOLD" },
},
resource: {
kind: "document",
id: "1",
attr: { owner: "user@example.com" },
},
action: "view",
},
});
t.deepEqual(res.result, { res: false }, JSON.stringify(res, null, 2));
});

test('Invalid access [action not allowed]', async (t) => {
const slangroom = new Slangroom(cerbos);
const res = await slangroom.execute(script, {
data: {
cerbos: "http://localhost:3592",
principal: {
id: "user@example.com",
roles: ["user"],
attr: { tier: "PREMIUM" },
},
resource: {
kind: "document",
id: "1",
attr: { owner: "user@example.com" },
},
action: "delete",
},
});
t.deepEqual(res.result, { res: false }, JSON.stringify(res, null, 2));
});

test('Invalid url', async (t) => {
const slangroom = new Slangroom(cerbos);
const fn = slangroom.execute(script, {
data: {
cerbos: "not_a_url",
principal: {
id: "user@example.com",
roles: ["user"],
attr: { tier: "PREMIUM" },
},
resource: {
kind: "document",
id: "1",
attr: { owner: "user@example.com" },
},
action: "view",
},
});
const error = await t.throwsAsync(fn);
t.is(stripAnsiCodes((error as Error).message),
`0 |
1 | Prepare 'res': connect to 'cerbos' and evaluate access with principal 'principal', resource 'resource', action 'action'
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
2 |
3 | Given I have a 'boolean' named 'res'

Error colors:
- error
- suggested words
- missing words
- extra words

Slangroom @slangroom/cerbos@${packageJson.version} Error: gRPC error 2 (UNKNOWN): Request failed: Failed to parse URL from not_a_url/api/check/resources

Heap:
{
"cerbos": "not_a_url",
"principal": {
"id": "user@example.com",
"roles": [
"user"
],
"attr": {
"tier": "PREMIUM"
}
},
"resource": {
"kind": "document",
"id": "1",
"attr": {
"owner": "user@example.com"
}
},
"action": "view"
}
`);
});
12 changes: 12 additions & 0 deletions pkg/cerbos/test/policies/_schemas/document.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"owner": {
"type": "string"
}
},
"required": [
"owner"
]
}
Loading
Loading