@pcg/auth
v1.0.0
Published
Authorization library
Downloads
1,221
Readme
@pcg/auth
Authorization library for checking user access rights. Supports a flexible permission system with scopes, wildcard access, and NestJS integration.
Installation
npm i @pcg/authQuick Start
import { isGranted, resolvePermissions } from '@pcg/auth';
import * as s from '@pcg/auth/scopes';
// Prepare user object
const user = {
id: 'hcu:2x6g7l8n9op',
permissions: ['js:core:episodes[org#hcorg:company1]:get'],
resolvedPermissions: resolvePermissions(['js:core:episodes[org#hcorg:company1]:get']),
};
// Check access
isGranted(user, 'js:core:episodes:get', s.org('hcorg:company1')); // true
isGranted(user, 'js:core:episodes:get', s.org('hcorg:other')); // falseExports
The library provides two entry points:
| Import | Description |
|--------|-------------|
| @pcg/auth | Core functions: isGranted, resolvePermissions, ActionScopes, ScopesBulder, types |
| @pcg/auth/scopes | Functional scope builders: org, id, user, and, anyScope, etc. |
Permission String Format
Pattern: <service>:<module>:<resource>[scopes]:<action>
| Component | Description | Examples |
|-----------|-------------|----------|
| service | Application identifier | js, tl, bo, * |
| module | Module within the service | core, mam, * |
| resource | Entity type | episodes, users, roles |
| scopes | Constraints (optional) | [org], [org,published], [org+published] |
| action | Operation | get, list, create, update, delete, * |
Permission String Examples
'js:core:episodes:get' // No scopes — full access to the action
'js:core:episodes[org]:get' // Restricted to organization
'js:core:episodes[org,published]:get' // OR — needs ANY of the scopes
'js:core:episodes[org+published]:get' // AND — needs ALL scopes
'js:core:episodes[org#hcorg:company1]:get' // Bound to a specific organization
'js:*:*:*' // Wildcard — all actions in the service
'*:*:*:*' // Super admin — everythingScope Logic
OR Logic (comma ,)
User is granted access if the action scope matches any of their scopes.
// Permission: 'js:core:episodes[org,published]:get'
// Resolved scopes: ['org', 'published']
isGranted(user, 'js:core:episodes:get', ['org']); // true
isGranted(user, 'js:core:episodes:get', ['published']); // true
isGranted(user, 'js:core:episodes:get', ['draft']); // falseAND Logic (plus +)
User is granted access only if the action scope contains all specified scopes.
// Permission: 'js:core:episodes[org+published]:get'
// Resolved scopes: [['org', 'published']] ← array within array
isGranted(user, 'js:core:episodes:get', ['org']); // false
isGranted(user, 'js:core:episodes:get', [['org', 'published']]); // trueMixed Logic
// Permission: 'js:core:episodes[published,org+draft]:get'
// Resolved scopes: ['published', ['org', 'draft']]
// Access granted if:
// - scope is 'published', OR
// - both 'org' AND 'draft' scopes
isGranted(user, 'js:core:episodes:get', ['published']); // true
isGranted(user, 'js:core:episodes:get', [['org', 'draft']]); // true
isGranted(user, 'js:core:episodes:get', ['org']); // falseScope ID (hash #)
Scopes can include a binding to a specific entity ID.
// Permission: 'js:core:episodes[org#hcorg:company1]:get'
isGranted(user, 'js:core:episodes:get', ['org#hcorg:company1']); // true
isGranted(user, 'js:core:episodes:get', ['org#hcorg:company2']); // falseWildcard (*)
The ['*'] action scope bypasses scope checking.
isGranted(user, 'js:core:episodes:get', ['*']); // true — if user has the permission with any scopeResults Matrix
| User Permission | Action Scopes | Result |
|-----------------|---------------|--------|
| episodes:get (no scopes) | any | true |
| episodes[org]:get | ['org'] | true |
| episodes[org]:get | ['published'] | false |
| episodes[org]:get | [] (empty) | false |
| episodes[org]:get | ['*'] | true |
| episodes[org,published]:get | ['org'] | true |
| episodes[org+published]:get | ['org'] | false |
| episodes[org+published]:get | [['org', 'published']] | true |
| episodes[org#hcorg:A]:get | ['org#hcorg:A'] | true |
| episodes[org#hcorg:A]:get | ['org#hcorg:B'] | false |
| js:*:*:* | any | true |
Types
IUser
The user object must contain resolvedPermissions:
interface IUser {
id: string; // 'hcu:2x6g7l8n9op'
permissions: readonly string[]; // Raw permission strings
resolvedPermissions: ReadonlyResolvedPermission[]; // Parsed permissions
}ResolvedPermission
interface ResolvedPermission {
id: string; // Permission without scope brackets
scopes: (string | string[])[]; // Parsed scopes
}
// Examples:
// 'js:core:episodes[org,published]:get'
// → { id: 'js:core:episodes:get', scopes: ['org', 'published'] }
//
// 'js:core:episodes[org+published]:get'
// → { id: 'js:core:episodes:get', scopes: [['org', 'published']] }API
isGranted(user, permission, actionScopes?)
Main function for checking access. Returns boolean.
import { isGranted } from '@pcg/auth';
import * as s from '@pcg/auth/scopes';
// Without scopes
isGranted(user, 'js:core:episodes:get');
// With single scope (no array needed)
isGranted(user, 'js:core:episodes:get', s.org('hcorg:company1'));
// With wildcard
isGranted(user, 'js:core:episodes:list', s.anyScope());resolvePermission(permission)
Parses a single permission string into an object.
import { resolvePermission } from '@pcg/auth';
resolvePermission('js:core:episodes[org,published]:get');
// { id: 'js:core:episodes:get', scopes: ['org', 'published'] }
resolvePermission('js:core:episodes[org+published]:get');
// { id: 'js:core:episodes:get', scopes: [['org', 'published']] }resolvePermissions(permissions)
Parses an array of permission strings. Matching IDs are merged — their scopes are combined.
import { resolvePermissions } from '@pcg/auth';
resolvePermissions([
'js:core:episodes[org]:get',
'js:core:episodes[published]:get',
]);
// [{ id: 'js:core:episodes:get', scopes: ['org', 'published'] }]mergeResolvedPermissions(array1, array2)
Merges two arrays of resolved permissions. Empty scopes ([]) mean full access and take precedence when merging.
import { mergeResolvedPermissions } from '@pcg/auth';
mergeResolvedPermissions(
[{ id: 'js:core:episodes:get', scopes: ['org#hci'] }],
[{ id: 'js:core:episodes:get', scopes: ['org#dv'] }],
);
// [{ id: 'js:core:episodes:get', scopes: ['org#hci', 'org#dv'] }]
// Empty scopes = full access
mergeResolvedPermissions(
[{ id: 'js:core:episodes:get', scopes: ['org#hci'] }],
[{ id: 'js:core:episodes:get', scopes: [] }],
);
// [{ id: 'js:core:episodes:get', scopes: [] }]encodeScopes(scopes)
Converts a scopes array back to string format.
import { encodeScopes } from '@pcg/auth';
encodeScopes(['org#xxx', 'user#xxx']);
// '[org#xxx,user#xxx]'
encodeScopes([['org#xxx', 'published']]);
// '[org#xxx+published]'injectScopesIntoPermission(permission, scopes)
Adds scopes to a permission string.
import { injectScopesIntoPermission } from '@pcg/auth';
injectScopesIntoPermission('js:core:episodes:create', ['org']);
// 'js:core:episodes[org]:create'
injectScopesIntoPermission('js:core:episodes[org]:create', ['shared']);
// 'js:core:episodes[org,shared]:create'replaceScope(scopes, from, to)
Replaces specific scope values in an array.
import { replaceScope } from '@pcg/auth';
replaceScope(['assigned'], 'assigned', 'brand#brd:xxx');
// ['brand#brd:xxx']
// Works with AND scopes
replaceScope([['assigned', 'lang']], 'assigned', 'brand#brd:xxx');
// [['brand#brd:xxx', 'lang']]
// Sequential replacement
let scopes = [['assigned', 'lang']];
scopes = replaceScope(scopes, 'assigned', 'brand#brd:xxx');
scopes = replaceScope(scopes, 'lang', 'lang#en');
// [['brand#brd:xxx', 'lang#en']]Functional Scope Builders (recommended)
Imported from @pcg/auth/scopes. Pure functions with no classes — convenient for inline use.
isGranted accepts a single scope string directly or an array for multiple scopes.
import * as s from '@pcg/auth/scopes';Available Functions
| Function | Returns | Example |
|----------|---------|---------|
| s.anyScope() | ActionScopesArray | s.anyScope() → ['*'] |
| s.org(id) | string | s.org('jsorg:hci') → 'org#jsorg:hci' |
| s.id(entityId) | string | s.id('ep:123') → 'id#ep:123' |
| s.user(id) | string | s.user('hcu:xxx') → 'user#hcu:xxx' |
| s.form(id) | string | s.form('contact') → 'form#contact' |
| s.group(id) | string | s.group('hcgrp:ZT9') → 'grp#hcgrp:ZT9' |
| s.scope(name, id?) | string | s.scope('orggroup', 'hcgrp:ZT9') → 'orggroup#hcgrp:ZT9' |
| s.and(...items) | string[] | s.and(s.org('hci'), 'published') → ['org#hci', 'published'] |
Usage Examples
import * as s from '@pcg/auth/scopes';
import { isGranted } from '@pcg/auth';
// Single scope — no array needed
isGranted(user, 'js:core:brands:update', s.org(brand.orgId));
// Multiple scopes (OR) — use array
isGranted(user, 'js:core:episodes:get', [s.org(episode.orgId), s.id(episode.id)]);
// AND scopes (reuse variable)
const org = s.org(episode.orgId);
isGranted(user, 'js:core:episodes:get', [org, s.and(org, 'published')]);
// Wildcard
isGranted(user, 'js:core:episodes:list', s.anyScope());
// Custom scope
isGranted(user, 'js:mam:episodes:create', s.scope('action', 'approve'));ActionScopes (legacy)
The ActionScopes class is still supported for backward compatibility. Use functional scope builders for new code.
import { ActionScopes, isGranted } from '@pcg/auth';
const scopes = new ActionScopes();
scopes.set('org', 'hcorg:company1'); // org#hcorg:company1
scopes.set('org+published'); // AND: ['org#hcorg:company1', 'published']
scopes.setEntityId('jse:episode123'); // id#jse:episode123
isGranted(user, 'js:core:episodes:get', scopes);ScopesBulder
Utility for building complex scope combinations.
import { ScopesBulder } from '@pcg/auth';
const builder = new ScopesBulder();
// Add scopes
builder.append('org#hci');
builder.extend(['org#hci', 'org#dv']);
// Join — creates AND combinations (cartesian product)
builder.extend(['published', 'draft']);
builder.join('org#hcc', 'before');
// [['org#hcc', 'published'], ['org#hcc', 'draft']]
// Replace prefix
builder.replacePrefix('org', 'id');
// ['org#hcorg:hci'] → ['id#hcorg:hci']
// Clone and build
const clone = builder.clone();
const scopes = builder.build();Example: Multi-language, multi-org access
const builder = new ScopesBulder();
builder.extend(['org#hci', 'org#dv']);
builder.join(['lang#en', 'lang#de']);
builder.build();
// [
// ['org#hci', 'lang#en'],
// ['org#hci', 'lang#de'],
// ['org#dv', 'lang#en'],
// ['org#dv', 'lang#de'],
// ]NestJS Integration
Basic CRUD
import * as s from '@pcg/auth/scopes';
@Resolver(() => Episode)
@UseGuards(GraphQLJwtAuthGuard, GraphQLPermissionsGuard)
export class EpisodesResolver {
@Query(() => Episode)
@UsePermission('js:core:episodes:get')
async episode(
@Args('id') id: string,
@ActionContextParam() ctx: ActionContext,
) {
const episode = await this.episodesService.findOne(id);
ctx.validateAccess(
'js:core:episodes:get',
[s.org(episode.organizationId), s.id(episode.id)],
);
return episode;
}
}Two-Level Permission Check
@UsePermission()— guard checks whether the user has access to the action with any scope (uses['*']wildcard)ctx.validateAccess()— inside the resolver, checks specific scopes for the given entity
import * as s from '@pcg/auth/scopes';
@Mutation(() => Episode)
@UsePermission('js:mam:episodes:update') // Step 1: does the user have this permission at all?
async updateEpisode(
@Args('input') input: UpdateEpisodeInput,
@ActionContextParam() ctx: ActionContext,
) {
const episode = await this.episodeService.findOne(input.id);
// Step 2: check the specific scope
ctx.validateAccess(
'js:mam:episodes:update',
s.org(episode.organizationId),
);
// If the episode belongs to company2 but the user only has access to company1 — throws an error
return this.episodeService.update(input, ctx);
}ctx.validateAccess() vs ctx.isGranted()
| Method | Behavior | When to use |
|--------|----------|-------------|
| ctx.validateAccess() | Throws an error if access is denied | When access is mandatory |
| ctx.isGranted() | Returns boolean | When branching logic is needed |
// validateAccess — throws if access is denied
ctx.validateAccess('js:core:episodes:get', s.org(episode.orgId));
// isGranted — for conditional logic
if (ctx.isGranted('js:core:episodes:list', s.anyScope())) {
return this.episodeService.findAll();
}Conditional Access: Full vs Scoped
import * as s from '@pcg/auth/scopes';
@Query(() => [Episode])
@UsePermission('js:mam:episodes:list')
async episodes(@ActionContextParam() ctx: ActionContext) {
// Admin: full access (permission without scopes)
if (ctx.isGranted('js:mam:episodes:list', s.anyScope())) {
return this.episodeService.findAll();
}
// User: organization-scoped access
if (ctx.isGranted(
'js:mam:episodes:list',
s.org(ctx.user.organizationId),
)) {
return this.episodeService.findByOrg(ctx.user.organizationId);
}
return [];
}Status-Based Access (published/draft)
import * as s from '@pcg/auth/scopes';
// Permission: 'js:mam:episodes[org+published]:get'
// Can only view published episodes within their organization
async getEpisode(id: string, ctx: ActionContext) {
const episode = await this.repository.findOne(id);
const org = s.org(episode.organizationId);
const items: s.ScopeItem[] = [org];
if (episode.status === 'published') {
items.push('published', s.and(org, 'published'));
} else if (episode.status === 'draft') {
items.push('draft', s.and(org, 'draft'));
}
ctx.validateAccess('js:mam:episodes:get', items);
return episode;
}Public Resources
import * as s from '@pcg/auth/scopes';
// AUTHORIZED: 'tl:core:forms[public]:get'
// ORGANIZATION_ADMIN: 'tl:core:forms[assigned-org]:get' → resolved to org#tlorg:xxx
@Query(() => Form)
@UsePermission('tl:core:forms:get')
async form(
@Args() input: FetchFormInput,
@ActionContextParam() ctx: ActionContext,
) {
const form = await this.formsService.getOneByOrFail(input, ctx);
const items: s.ScopeItem[] = [
s.org(form.organizationId),
s.id(form.id),
];
if (form.audienceType === FormAudienceType.PUBLIC) {
items.push('public');
}
ctx.validateAccess('tl:core:forms:get', items);
return form;
}Preparing the User Object
Permission strings are stored in the database as an array of strings. Before checking access, they must be parsed into resolvedPermissions:
import { resolvePermissions } from '@pcg/auth';
const rawPermissions = [
'js:core:episodes[org]:get',
'js:core:episodes[published]:get',
'js:core:episodes[org]:create',
'js:mam:*[org]:*',
];
const user = {
id: 'hcu:2x6g7l8n9op',
permissions: rawPermissions,
resolvedPermissions: resolvePermissions(rawPermissions),
};
// resolvedPermissions:
// [
// { id: 'js:core:episodes:get', scopes: ['org', 'published'] },
// { id: 'js:core:episodes:create', scopes: ['org'] },
// { id: 'js:mam:*:*', scopes: ['org'] },
// ]Best Practices
- Permission naming: lowercase with colons —
js:core:episodes:get - Standard actions:
get,list,create,update,delete - Scopes: use
orgfor organization,id#for entity - Functional builders: prefer
@pcg/auth/scopesover theActionScopesclass - Checks in resolvers: all scope checks should be done in the resolver, NOT in the service layer
@UsePermission: for basic access control at the guard levelctx.validateAccess(): for scope-specific checks inside the resolver- Do not use
assigned-org,assigned-form,assigned-groupin action scopes — these are meant for role definitions and are automatically resolved toorg#xxx/id#xxx
