diff --git a/.eslintrc.json b/.eslintrc.json index afd4fd4..ffba895 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -4,7 +4,7 @@ "parserOptions": { "ecmaVersion": 2020, "sourceType": "module", - "project": "./tsconfig.json" + "project": "./tsconfig.eslint.json" }, "plugins": ["@typescript-eslint"], "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], @@ -21,5 +21,13 @@ ], "no-console": "warn" }, - "ignorePatterns": ["dist/", "node_modules/", "*.js", "*.mjs"] + "ignorePatterns": ["dist/", "node_modules/", "*.js", "*.mjs"], + "overrides": [ + { + "files": ["**/*.test.ts", "**/*.spec.ts"], + "rules": { + "no-empty": "off" + } + } + ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 035d9b0..4e6fabd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,29 +1,37 @@ -# Changelog - -All notable changes to the Permissio.io Node.js SDK will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Added -- Initial SDK implementation -- Permission checking with `check()` method -- Auto-scope detection from API key -- Full CRUD operations for Users, Roles, Tenants, and Resources -- Express middleware integration -- TypeScript support with full type definitions -- Comprehensive examples - -## [1.0.0] - 2024-XX-XX - -### Added -- Initial release -- `Permisio` client class -- `PermisioConfig` for configuration -- `UserBuilder` and `ResourceBuilder` for building check requests -- API clients for Users, Roles, Tenants, Resources, and Role Assignments -- Express middleware for permission enforcement -- Full TypeScript types and documentation -- Examples for common use cases +# Changelog + +All notable changes to the Permissio.io Node.js SDK will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +No unreleased changes at this time. + +--- + +## [1.0.0-alpha.1] - 2025-03-15 + +### Added +- **`Permissio` client class**: Main entry point with `token`-based configuration and auto-scope detection +- **`IPermissioConfig` interface**: Full configuration support — `token`, `apiUrl`, `projectId`, `environmentId`, `debug`, `timeout`, `retryAttempts`, `throwOnError`, `customHeaders` +- **Permission checking**: + - `check()` — simple boolean permission check + - `checkWithDetails()` — full `ICheckResponse` with reason and debug info + - `bulkCheck()` — batch permission checks in a single request + - `checkAndThrow()` — throws `PermissioApiError` on denial + - `getPermissions()` — retrieve all permissions for a user +- **Auto-scope detection**: Automatically fetches `projectId` and `environmentId` from the `/v1/api-key/scope` endpoint when not provided explicitly +- **Users API** (`api.users`): `list()`, `get()`, `create()`, `update()`, `delete()`, `sync()`, `getRoles()`, `assignRole()`, `unassignRole()`, `getTenants()` +- **Tenants API** (`api.tenants`): `list()`, `get()`, `create()`, `update()`, `delete()`, `sync()`, `getUsers()`, `addUser()`, `removeUser()` +- **Roles API** (`api.roles`): `list()`, `get()`, `create()`, `update()`, `delete()`, `sync()`, `getPermissions()`, `addPermission()`, `removePermission()`, `getExtends()`, `addExtends()`, `removeExtends()` +- **Resources API** (`api.resources`): `list()`, `get()`, `create()`, `update()`, `delete()`, `sync()`, `getActions()`, `addAction()`, `removeAction()`, `listInstances()`, `getInstance()`, `createInstance()`, `deleteInstance()`, `syncInstance()` +- **Role Assignments API** (`api.roleAssignments`): `list()`, `listByUser()`, `listByTenant()`, `listByResource()`, `assign()`, `unassign()`, `bulkAssign()`, `bulkUnassign()`, `hasRole()`, `getUserRoles()`, `getRoleUsers()` +- **`syncUser()` convenience method**: Create or update a user with roles in a single call +- **`PermissioApiError`**: Typed error class with `statusCode`, `code`, `details`, and `originalError` +- **Full TypeScript support**: Complete type definitions for all request/response shapes exported from the package +- **Dual CJS/ESM output**: Bundled via `tsup` for compatibility with both CommonJS and ES Module environments +- **Retry logic**: Automatic exponential-backoff retries on transient errors (408, 429, 5xx) +- **Request routing**: Schema-based routes (`/v1/schema/...`) and facts-based routes (`/v1/facts/...`) handled transparently +- **Examples**: Common use-case examples for permission checking, user management, and role assignment diff --git a/README.md b/README.md index e56e58f..83fc1cf 100644 --- a/README.md +++ b/README.md @@ -12,16 +12,16 @@ yarn add permissio pnpm add permissio ``` +**Requirements**: Node.js 16+ + ## Quick Start ```typescript import { Permissio } from 'permissio'; -// Initialize the SDK +// Simplest usage — project and environment are auto-detected from the API key const permissio = new Permissio({ token: 'permis_key_your_api_key_here', - projectId: 'your-project-id', - environmentId: 'your-environment-id', }); // Check permissions @@ -44,13 +44,14 @@ if (allowed) { import { Permissio } from 'permissio'; const permissio = new Permissio({ - // Required: Your API key + // Required: Your API key (must start with "permis_key_") token: 'permis_key_your_api_key_here', // Optional: API base URL (defaults to https://api.permissio.io) apiUrl: 'https://api.permissio.io', // Optional: Project and Environment IDs + // If omitted, they are auto-fetched from the /v1/api-key/scope endpoint projectId: 'your-project-id', environmentId: 'your-environment-id', @@ -60,13 +61,13 @@ const permissio = new Permissio({ // Optional: Request timeout in ms (default: 30000) timeout: 30000, - // Optional: Number of retry attempts (default: 3) + // Optional: Number of retry attempts for transient errors (default: 3) retryAttempts: 3, - // Optional: Throw errors or return false (default: true) + // Optional: Throw errors or return false on denial (default: true) throwOnError: true, - // Optional: Custom headers + // Optional: Custom headers sent with every request customHeaders: { 'X-Custom-Header': 'value', }, @@ -109,7 +110,7 @@ const allowed = await permissio.check({ }); ``` -### Check with User Attributes +### Check with User Attributes (ABAC) ```typescript const allowed = await permissio.check({ @@ -135,8 +136,8 @@ const response = await permissio.checkWithDetails({ }); console.log(response.allowed); // boolean -console.log(response.reason); // string (optional) -console.log(response.debug); // debug info (when debug enabled) +console.log(response.reason); // string (optional) +console.log(response.debug); // debug info (when debug mode enabled) ``` ### Bulk Permission Checks @@ -164,13 +165,25 @@ try { action: 'delete', resource: 'document', }); - // Access granted, continue with operation + // Access granted — continue with operation } catch (error) { - // Access denied + // Access denied — error is a PermissioApiError console.error(error.message); } ``` +### Get All User Permissions + +```typescript +const permissions = await permissio.getPermissions({ + user: 'user@example.com', + tenant: 'acme-corp', +}); + +console.log(permissions.roles); // ['admin', 'editor'] +console.log(permissions.permissions); // ['document:read', 'document:write', ...] +``` + ## User Management ### Create a User @@ -198,7 +211,7 @@ const user = await permissio.api.users.sync({ }); ``` -### Sync User with Roles +### Sync User with Roles (Convenience Method) ```typescript await permissio.syncUser({ @@ -229,10 +242,17 @@ const users = await permissio.api.users.list({ const roles = await permissio.api.users.getRoles('user@example.com'); ``` -### Assign Role to User +### Assign / Unassign Role to User ```typescript await permissio.api.users.assignRole('user@example.com', 'admin', 'acme-corp'); +await permissio.api.users.unassignRole('user@example.com', 'admin', 'acme-corp'); +``` + +### Get User Tenants + +```typescript +const tenants = await permissio.api.users.getTenants('user@example.com'); ``` ## Tenant Management @@ -250,16 +270,21 @@ const tenant = await permissio.api.tenants.create({ }); ``` -### Get Tenant Users +### Sync Tenant (Create or Update) ```typescript -const users = await permissio.api.tenants.getUsers('acme-corp'); +await permissio.api.tenants.sync({ + key: 'acme-corp', + name: 'Acme Corporation', +}); ``` -### Add User to Tenant +### Manage Tenant Users ```typescript +const users = await permissio.api.tenants.getUsers('acme-corp'); await permissio.api.tenants.addUser('acme-corp', 'user@example.com'); +await permissio.api.tenants.removeUser('acme-corp', 'user@example.com'); ``` ## Role Management @@ -275,13 +300,25 @@ const role = await permissio.api.roles.create({ }); ``` -### Add Permission to Role +### Sync Role (Create or Update) + +```typescript +await permissio.api.roles.sync({ + key: 'editor', + name: 'Editor', + permissions: ['document:read', 'document:write'], +}); +``` + +### Manage Role Permissions ```typescript await permissio.api.roles.addPermission('editor', 'document:delete'); +await permissio.api.roles.removePermission('editor', 'document:delete'); +const permissions = await permissio.api.roles.getPermissions('editor'); ``` -### Role Inheritance +### Role Inheritance (Extends) ```typescript // Create a role that extends another @@ -291,6 +328,11 @@ await permissio.api.roles.create({ extends: ['editor'], permissions: ['document:delete', 'user:manage'], }); + +// Manage extends at runtime +await permissio.api.roles.addExtends('admin', 'editor'); +await permissio.api.roles.removeExtends('admin', 'editor'); +const parents = await permissio.api.roles.getExtends('admin'); ``` ## Resource Management @@ -305,9 +347,28 @@ const resource = await permissio.api.resources.create({ }); ``` -### Create a Resource Instance +### Sync Resource (Create or Update) + +```typescript +await permissio.api.resources.sync({ + key: 'document', + name: 'Document', + actions: ['read', 'write', 'delete'], +}); +``` + +### Manage Resource Actions ```typescript +await permissio.api.resources.addAction('document', 'archive'); +await permissio.api.resources.removeAction('document', 'archive'); +const actions = await permissio.api.resources.getActions('document'); +``` + +### Resource Instances + +```typescript +// Create an instance const instance = await permissio.api.resources.createInstance({ key: 'doc-123', resourceType: 'document', @@ -317,6 +378,16 @@ const instance = await permissio.api.resources.createInstance({ owner: 'user@example.com', }, }); + +// List, get, delete, sync instances +const instances = await permissio.api.resources.listInstances('document'); +const inst = await permissio.api.resources.getInstance('document', 'doc-123'); +await permissio.api.resources.deleteInstance('document', 'doc-123'); +await permissio.api.resources.syncInstance({ + key: 'doc-123', + resourceType: 'document', + tenant: 'acme-corp', +}); ``` ## Role Assignments @@ -331,7 +402,7 @@ await permissio.api.roleAssignments.assign({ }); ``` -### Assign Role on Resource +### Assign Role on a Resource Instance ```typescript await permissio.api.roleAssignments.assign({ @@ -342,7 +413,7 @@ await permissio.api.roleAssignments.assign({ }); ``` -### Bulk Role Assignment +### Bulk Assign / Unassign Roles ```typescript const result = await permissio.api.roleAssignments.bulkAssign([ @@ -350,11 +421,29 @@ const result = await permissio.api.roleAssignments.bulkAssign([ { user: 'user2@example.com', role: 'editor', tenant: 'acme-corp' }, { user: 'user3@example.com', role: 'admin', tenant: 'acme-corp' }, ]); - console.log(`Created: ${result.created}, Failed: ${result.failed}`); + +await permissio.api.roleAssignments.bulkUnassign([ + { user: 'user1@example.com', role: 'viewer', tenant: 'acme-corp' }, +]); ``` -### Check if User Has Role +### List Assignments + +```typescript +// General list with filters +const all = await permissio.api.roleAssignments.list({ + user: 'user@example.com', + tenant: 'acme-corp', +}); + +// Filtered by entity +const byUser = await permissio.api.roleAssignments.listByUser('user@example.com'); +const byTenant = await permissio.api.roleAssignments.listByTenant('acme-corp'); +const byResource = await permissio.api.roleAssignments.listByResource('document', 'doc-123'); +``` + +### Check Role / Get Role Users ```typescript const hasRole = await permissio.api.roleAssignments.hasRole( @@ -362,18 +451,9 @@ const hasRole = await permissio.api.roleAssignments.hasRole( 'admin', { tenant: 'acme-corp' } ); -``` -## Get User Permissions - -```typescript -const permissions = await permissio.getPermissions({ - user: 'user@example.com', - tenant: 'acme-corp', -}); - -console.log(permissions.roles); // ['admin', 'editor'] -console.log(permissions.permissions); // ['document:read', 'document:write', ...] +const userRoles = await permissio.api.roleAssignments.getUserRoles('user@example.com'); +const roleUsers = await permissio.api.roleAssignments.getRoleUsers('admin'); ``` ## Error Handling @@ -406,7 +486,7 @@ const permissio = new Permissio({ throwOnError: false, }); -// Will return false instead of throwing +// Returns false instead of throwing on access denial const allowed = await permissio.check({ user: 'user@example.com', action: 'read', @@ -420,8 +500,8 @@ This SDK is written in TypeScript and provides full type definitions. ```typescript import { - Permis, - IPermisConfig, + Permissio, + IPermissioConfig, ICheckRequest, ICheckResponse, IUserCreate, @@ -429,7 +509,7 @@ import { IRoleAssignmentCreate, } from 'permissio'; -const config: IPermisConfig = { +const config: IPermissioConfig = { token: 'permis_key_...', projectId: 'my-project', environmentId: 'production', @@ -505,10 +585,10 @@ export const RequirePermission = (action: string, resource: string) => SetMetadata(PERMISSION_KEY, { action, resource }); @Injectable() -export class PermisGuard implements CanActivate { +export class PermissioGuard implements CanActivate { constructor( private reflector: Reflector, - private permis: Permis + private permissio: Permissio ) {} async canActivate(context: ExecutionContext): Promise { diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..641fc82 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,22 @@ +/** @type {import('jest').Config} */ +const config = { + preset: "ts-jest", + testEnvironment: "node", + roots: ["/src"], + testMatch: ["**/__tests__/**/*.test.ts", "**/*.test.ts"], + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + tsconfig: { + module: "commonjs", + esModuleInterop: true, + }, + }, + ], + }, + testTimeout: 30000, + verbose: true, +}; + +module.exports = config; diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts new file mode 100644 index 0000000..0bb3ac3 --- /dev/null +++ b/src/__tests__/integration.test.ts @@ -0,0 +1,306 @@ +/** + * Integration tests for the Permissio.io Node.js SDK. + * + * Prerequisites: + * - Backend running at http://localhost:3001 + * - PERMIS_API_KEY environment variable set (tests are skipped if absent) + * + * Run with: PERMIS_API_KEY= npm test + */ + +import { Permissio } from "../permissio"; + +const API_KEY = process.env.PERMIS_API_KEY; +const API_URL = process.env.PERMIS_API_URL ?? "http://localhost:3001"; + +// Unique suffix per test run to avoid collisions +const TS = Date.now(); +const TEST_USER_KEY = `test-user-${TS}@integration.test`; +const TEST_TENANT_KEY = `test-tenant-${TS}`; +const TEST_ROLE_KEY = `test-role-${TS}`; +const TEST_RESOURCE_KEY = `test-resource-${TS}`; + +// Skip all integration tests when no real backend is available (e.g. CI without a server). +// Set PERMIS_API_KEY to opt-in and run against a live backend. +const itIntegration = API_KEY ? it : it.skip; + +describe("Permissio Node.js SDK — Integration", () => { + let client: Permissio; + + beforeAll(async () => { + if (!API_KEY) return; + client = new Permissio({ token: API_KEY, apiUrl: API_URL }); + // Pre-fetch scope so all API clients have project/env IDs ready + await client.getScope(); + }); + + afterAll(async () => { + if (!client) return; + try { + await client.api.roleAssignments.unassign({ + user: TEST_USER_KEY, + role: TEST_ROLE_KEY, + tenant: TEST_TENANT_KEY, + }); + } catch (_) {} + try { await client.api.users.delete(TEST_USER_KEY); } catch (_) {} + try { await client.api.tenants.delete(TEST_TENANT_KEY); } catch (_) {} + try { await client.api.roles.delete(TEST_ROLE_KEY); } catch (_) {} + try { await client.api.resources.delete(TEST_RESOURCE_KEY); } catch (_) {} + }); + + // ─── 1. API Key Scope ─────────────────────────────────────────────────────── + + describe("1. API Key Scope", () => { + itIntegration("auto-fetches project and environment IDs from the API key", async () => { + const scope = await client.getScope(); + console.log("[SDK] Scope:", scope); + expect(scope.projectId).toBeTruthy(); + expect(scope.environmentId).toBeTruthy(); + }); + }); + + // ─── 2. Users CRUD ────────────────────────────────────────────────────────── + + describe("2. Users CRUD", () => { + itIntegration("creates a user", async () => { + const user = await client.api.users.create({ + key: TEST_USER_KEY, + email: TEST_USER_KEY, + firstName: "Integration", + lastName: "Test", + }); + console.log("[SDK] Created user:", user.key); + expect(user.key).toBe(TEST_USER_KEY); + }); + + itIntegration("lists users and finds the created one", async () => { + const result = await client.api.users.list(); + const found = result.data.some((u) => u.key === TEST_USER_KEY); + expect(found).toBe(true); + }); + + itIntegration("gets a user by key", async () => { + const user = await client.api.users.get(TEST_USER_KEY); + expect(user.key).toBe(TEST_USER_KEY); + }); + + itIntegration("syncs (upserts) a user", async () => { + const user = await client.api.users.sync({ + key: TEST_USER_KEY, + email: TEST_USER_KEY, + firstName: "Integration-Updated", + lastName: "Test", + }); + expect(user.key).toBe(TEST_USER_KEY); + }); + }); + + // ─── 3. Tenants CRUD ──────────────────────────────────────────────────────── + + describe("3. Tenants CRUD", () => { + itIntegration("creates a tenant", async () => { + const tenant = await client.api.tenants.create({ + key: TEST_TENANT_KEY, + name: `Integration Test Tenant ${TS}`, + }); + console.log("[SDK] Created tenant:", tenant.key); + expect(tenant.key).toBe(TEST_TENANT_KEY); + }); + + itIntegration("lists tenants and finds the created one", async () => { + const result = await client.api.tenants.list(); + const found = result.data.some((t) => t.key === TEST_TENANT_KEY); + expect(found).toBe(true); + }); + + itIntegration("gets a tenant by key", async () => { + const tenant = await client.api.tenants.get(TEST_TENANT_KEY); + expect(tenant.key).toBe(TEST_TENANT_KEY); + }); + }); + + // ─── 4. Resources CRUD ────────────────────────────────────────────────────── + + describe("4. Resources CRUD", () => { + itIntegration("creates a resource type", async () => { + const resource = await client.api.resources.create({ + key: TEST_RESOURCE_KEY, + name: `Integration Test Resource ${TS}`, + actions: ["read", "write"], + }); + console.log("[SDK] Created resource:", resource.key); + expect(resource.key).toBe(TEST_RESOURCE_KEY); + }); + + itIntegration("lists resources and finds the created one", async () => { + const result = await client.api.resources.list(); + const found = result.data.some((r) => r.key === TEST_RESOURCE_KEY); + expect(found).toBe(true); + }); + }); + + // ─── 5. Roles CRUD ────────────────────────────────────────────────────────── + + describe("5. Roles CRUD", () => { + itIntegration("creates a role with permissions", async () => { + const role = await client.api.roles.create({ + key: TEST_ROLE_KEY, + name: `Integration Test Role ${TS}`, + permissions: [`${TEST_RESOURCE_KEY}:read`, `${TEST_RESOURCE_KEY}:write`], + }); + console.log("[SDK] Created role:", role.key); + expect(role.key).toBe(TEST_ROLE_KEY); + expect(role.permissions).toContain(`${TEST_RESOURCE_KEY}:read`); + }); + + itIntegration("lists roles and finds the created one", async () => { + const result = await client.api.roles.list(); + const found = result.data.some((r) => r.key === TEST_ROLE_KEY); + expect(found).toBe(true); + }); + + itIntegration("gets a role by key", async () => { + const role = await client.api.roles.get(TEST_ROLE_KEY); + expect(role.key).toBe(TEST_ROLE_KEY); + expect(Array.isArray(role.permissions)).toBe(true); + }); + }); + + // ─── 6. Role Assignments ──────────────────────────────────────────────────── + + describe("6. Role Assignments", () => { + itIntegration("assigns a role to a user in a tenant", async () => { + const assignment = await client.api.roleAssignments.assign({ + user: TEST_USER_KEY, + role: TEST_ROLE_KEY, + tenant: TEST_TENANT_KEY, + }); + console.log("[SDK] Assigned role:", assignment.user, "->", assignment.role); + expect(assignment.user).toBe(TEST_USER_KEY); + expect(assignment.role).toBe(TEST_ROLE_KEY); + }); + + itIntegration("lists role assignments for the user", async () => { + const result = await client.api.roleAssignments.list({ user: TEST_USER_KEY }); + expect(result.data.length).toBeGreaterThan(0); + const found = result.data.some( + (a) => a.user === TEST_USER_KEY && a.role === TEST_ROLE_KEY + ); + expect(found).toBe(true); + }); + }); + + // ─── 7. check() — allowed ─────────────────────────────────────────────────── + + describe("7. Permission Check — allowed", () => { + itIntegration("returns true when user has the required permission via role", async () => { + const allowed = await client.check({ + user: TEST_USER_KEY, + action: "read", + resource: TEST_RESOURCE_KEY, + tenant: TEST_TENANT_KEY, + }); + console.log(`[SDK] check() read allowed: ${allowed}`); + expect(allowed).toBe(true); + }); + }); + + // ─── 8. check() — denied ──────────────────────────────────────────────────── + + describe("8. Permission Check — denied", () => { + itIntegration("returns false for a user with no role assignment", async () => { + const allowed = await client.check({ + user: `no-role-${TS}@integration.test`, + action: "read", + resource: TEST_RESOURCE_KEY, + tenant: TEST_TENANT_KEY, + }); + expect(allowed).toBe(false); + }); + + itIntegration("returns false for an action not in the role permissions", async () => { + const allowed = await client.check({ + user: TEST_USER_KEY, + action: "delete", + resource: TEST_RESOURCE_KEY, + tenant: TEST_TENANT_KEY, + }); + // Role only has read + write + expect(allowed).toBe(false); + }); + }); + + // ─── 9. bulkCheck() ───────────────────────────────────────────────────────── + + describe("9. Bulk Permission Check", () => { + itIntegration("evaluates multiple checks at once", async () => { + const response = await client.bulkCheck({ + checks: [ + { user: TEST_USER_KEY, action: "read", resource: TEST_RESOURCE_KEY, tenant: TEST_TENANT_KEY }, + { user: TEST_USER_KEY, action: "write", resource: TEST_RESOURCE_KEY, tenant: TEST_TENANT_KEY }, + { user: TEST_USER_KEY, action: "delete", resource: TEST_RESOURCE_KEY, tenant: TEST_TENANT_KEY }, + ], + }); + console.log( + "[SDK] bulkCheck:", + response.results.map((r) => `${r.request.action}=${r.response.allowed}`) + ); + expect(response.results).toHaveLength(3); + expect(response.results[0].response.allowed).toBe(true); // read + expect(response.results[1].response.allowed).toBe(true); // write + expect(response.results[2].response.allowed).toBe(false); // delete — not in role + }); + }); + + // ─── 10. getPermissions() ─────────────────────────────────────────────────── + + describe("10. getPermissions()", () => { + itIntegration("returns all roles and permissions for a user", async () => { + const result = await client.getPermissions({ + user: TEST_USER_KEY, + tenant: TEST_TENANT_KEY, + }); + console.log("[SDK] getPermissions:", result); + expect(Array.isArray(result.roles)).toBe(true); + expect(Array.isArray(result.permissions)).toBe(true); + expect(result.roles).toContain(TEST_ROLE_KEY); + expect(result.permissions).toContain(`${TEST_RESOURCE_KEY}:read`); + }); + }); + + // ─── 11. syncUser() ───────────────────────────────────────────────────────── + + describe("11. syncUser()", () => { + itIntegration("syncs a user via the convenience method", async () => { + const user = await client.syncUser({ + key: TEST_USER_KEY, + email: TEST_USER_KEY, + firstName: "Synced", + lastName: "User", + }); + console.log("[SDK] syncUser:", user?.key ?? user); + expect(user).toBeTruthy(); + }); + }); + + // ─── 12. Unassign role ────────────────────────────────────────────────────── + + describe("12. Role Assignment — unassign", () => { + itIntegration("unassigns a role and verifies it is removed", async () => { + await client.api.roleAssignments.unassign({ + user: TEST_USER_KEY, + role: TEST_ROLE_KEY, + tenant: TEST_TENANT_KEY, + }); + const result = await client.api.roleAssignments.list({ + user: TEST_USER_KEY, + tenant: TEST_TENANT_KEY, + }); + const stillAssigned = result.data.some( + (a) => a.user === TEST_USER_KEY && a.role === TEST_ROLE_KEY + ); + expect(stillAssigned).toBe(false); + }); + }); +}); diff --git a/src/api/base.ts b/src/api/base.ts index e0ba41d..4e8e8dc 100644 --- a/src/api/base.ts +++ b/src/api/base.ts @@ -290,10 +290,12 @@ export class BaseApiClient { } /** - * Make a DELETE request + * Make a DELETE request (optionally with a JSON body) */ - protected async httpDelete(path: string): Promise { - const response = await this.client.delete(this.buildPath(path)); + protected async httpDelete(path: string, data?: unknown): Promise { + const response = await this.client.delete(this.buildPath(path), { + data, + }); return response.data; } } diff --git a/src/api/resources.ts b/src/api/resources.ts index d23d68c..2f10543 100644 --- a/src/api/resources.ts +++ b/src/api/resources.ts @@ -37,9 +37,14 @@ export class ResourcesApi extends BaseApiClient { /** * Create a new resource type * @param resourceData - Resource type creation data + * Actions can be passed as a string array (["read","write"]) for convenience; + * the SDK converts them to the map format the backend expects. */ async create(resourceData: IResourceCreate): Promise { - return this.httpPost("/schema/resources", resourceData); + return this.httpPost( + "/schema/resources", + this.normalizeActions(resourceData) + ); } /** @@ -53,7 +58,7 @@ export class ResourcesApi extends BaseApiClient { ): Promise { return this.httpPatch( `/schema/resources/${encodeURIComponent(resourceKey)}`, - resourceData + this.normalizeActions(resourceData) ); } @@ -74,10 +79,28 @@ export class ResourcesApi extends BaseApiClient { async sync(resourceData: IResourceCreate): Promise { return this.httpPut( `/schema/resources/${encodeURIComponent(resourceData.key)}`, - resourceData + this.normalizeActions(resourceData) ); } + /** + * Normalize actions from string[] convenience format to the map format + * the backend expects: { [actionKey]: { name: string } } + */ + private normalizeActions }>( + data: T + ): T { + if (!data.actions || !Array.isArray(data.actions)) { + return data; + } + const actionsMap: Record = {}; + for (const action of data.actions) { + const key = String(action); + actionsMap[key] = { name: key.charAt(0).toUpperCase() + key.slice(1) }; + } + return { ...data, actions: actionsMap }; + } + /** * Get actions for a resource type * @param resourceKey - The unique resource type key diff --git a/src/api/role-assignments.ts b/src/api/role-assignments.ts index 8bb0981..59d997d 100644 --- a/src/api/role-assignments.ts +++ b/src/api/role-assignments.ts @@ -124,15 +124,13 @@ export class RoleAssignmentsApi extends BaseApiClient { * @param assignment - Role assignment to remove */ async unassign(assignment: IRoleAssignmentRemove): Promise { - const params = new URLSearchParams(); - params.append("user", assignment.user); - params.append("role", assignment.role); - if (assignment.tenant) params.append("tenant", assignment.tenant); - if (assignment.resource) params.append("resource", assignment.resource); - if (assignment.resourceInstance) - params.append("resourceInstance", assignment.resourceInstance); - - return this.httpDelete(`/role_assignments?${params.toString()}`); + // Backend DELETE endpoint expects a JSON body, not query params + return this.httpDelete("/role_assignments", { + user: assignment.user, + role: assignment.role, + tenant: assignment.tenant, + resource_instance: assignment.resourceInstance, + }); } /** diff --git a/src/index.ts b/src/index.ts index dc33978..e688920 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,7 +75,6 @@ export type { IRoleRead, IRoleList, // Resource types - IResource, IResourceCreate, IResourceUpdate, IResourceRead, diff --git a/src/types/index.ts b/src/types/index.ts index 4a2e4e8..123c3bb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -27,7 +27,6 @@ export type { // Resource types export type { - IResource, IResourceCreate, IResourceUpdate, IResourceRead, diff --git a/src/types/resource.ts b/src/types/resource.ts index 00c56f3..bfc1198 100644 --- a/src/types/resource.ts +++ b/src/types/resource.ts @@ -1,22 +1,24 @@ /** - * Resource type definition representing a type of resource in the system + * Resource action definition (as returned by the backend) */ -export interface IResource { - key: string; +export interface IResourceAction { + id?: string; + key?: string; name?: string; description?: string; - actions?: string[]; - attributes?: Record; } /** - * Resource type creation payload + * Resource type creation payload. + * Actions can be provided as an array of string keys (SDK convenience) + * or as a map { [actionKey]: { name: ... } } (raw backend format). */ export interface IResourceCreate { key: string; name?: string; description?: string; - actions?: string[]; + /** Action keys (e.g. ["read", "write"]) — will be converted to map format */ + actions?: string[] | Record; attributes?: Record; } @@ -26,7 +28,7 @@ export interface IResourceCreate { export interface IResourceUpdate { name?: string; description?: string; - actions?: string[]; + actions?: string[] | Record; attributes?: Record; } @@ -38,10 +40,14 @@ export interface IResourceRead { key: string; name?: string; description?: string; - actions?: string[]; + /** Actions map as returned by the backend */ + actions?: Record; attributes?: Record; - createdAt: string; - updatedAt: string; + created_at?: string; + updated_at?: string; + // Convenience aliases + createdAt?: string; + updatedAt?: string; } /** @@ -49,10 +55,13 @@ export interface IResourceRead { */ export interface IResourceList { data: IResourceRead[]; - page: number; - perPage: number; - total: number; - totalPages: number; + total_count?: number; + page_count?: number; + // Legacy aliases + page?: number; + perPage?: number; + total?: number; + totalPages?: number; } /** diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json new file mode 100644 index 0000000..631f88a --- /dev/null +++ b/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}