feat: generated UI API key management + OAuth-like API Key Flows (#1609)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* API Key Authorization flow with consent screen, callback support, and
a Tools page.
* Schema-driven API Key creation UI with permission presets, templates,
and Developer Authorization Link.
* Effective Permissions preview and a new multi-select permission
control.

* **UI Improvements**
* Mask/toggle API keys, copy-to-clipboard with toasts, improved select
labels, new label styles, tab wrapping, and accordionized color
controls.

* **Documentation**
  * Public guide for the API Key authorization flow and scopes added.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Eli Bosley
2025-08-27 12:37:39 -04:00
committed by GitHub
parent 6947b5d4af
commit 674323fd87
119 changed files with 7996 additions and 1459 deletions

View File

@@ -1,9 +1,8 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ApiKey, ApiKeyWithSecret, Permission, Role } from '@unraid/shared/graphql.model.js';
import { ApiKeyService } from '@unraid/shared/services/api-key.js';
import { ApiKey, AuthAction, Permission, Resource, Role } from '@unraid/shared/graphql.model.js';
import { ApiKeyService, CreatePermissionsInput } from '@unraid/shared/services/api-key.js';
import { API_KEY_SERVICE_TOKEN } from '@unraid/shared/tokens.js';
import { AuthActionVerb } from 'nest-authz';
@Injectable()
export class ConnectApiKeyService implements ApiKeyService {
@@ -22,15 +21,15 @@ export class ConnectApiKeyService implements ApiKeyService {
return this.apiKeyService.findById(id);
}
findByIdWithSecret(id: string): ApiKeyWithSecret | null {
findByIdWithSecret(id: string): ApiKey | null {
return this.apiKeyService.findByIdWithSecret(id);
}
findByField(field: keyof ApiKeyWithSecret, value: string): ApiKeyWithSecret | null {
findByField(field: keyof ApiKey, value: string): ApiKey | null {
return this.apiKeyService.findByField(field, value);
}
findByKey(key: string): ApiKeyWithSecret | null {
findByKey(key: string): ApiKey | null {
return this.apiKeyService.findByKey(key);
}
@@ -38,9 +37,9 @@ export class ConnectApiKeyService implements ApiKeyService {
name: string;
description?: string;
roles?: Role[];
permissions?: Permission[] | { resource: string; actions: AuthActionVerb[] }[];
permissions?: CreatePermissionsInput;
overwrite?: boolean;
}): Promise<ApiKeyWithSecret> {
}): Promise<ApiKey> {
return this.apiKeyService.create(input);
}
@@ -67,7 +66,7 @@ export class ConnectApiKeyService implements ApiKeyService {
/**
* Creates a local API key specifically for Connect
*/
public async createLocalConnectApiKey(): Promise<ApiKeyWithSecret | null> {
public async createLocalConnectApiKey(): Promise<ApiKey | null> {
try {
return await this.create({
name: ConnectApiKeyService.CONNECT_API_KEY_NAME,

View File

@@ -1,9 +1,7 @@
import { Query, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
@@ -22,9 +20,8 @@ export class CloudResolver {
) {}
@Query(() => Cloud)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.CLOUD,
possession: AuthPossession.ANY,
})
public async cloud(): Promise<Cloud> {
const minigraphql = this.cloudService.checkMothershipClient();

View File

@@ -1,10 +1,8 @@
import { Query, ResolveField, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { AccessUrl } from '@unraid/shared/network.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
@@ -16,9 +14,8 @@ export class NetworkResolver {
constructor(private readonly urlResolverService: UrlResolverService) {}
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.NETWORK,
possession: AuthPossession.ANY,
})
@Query(() => Network)
public async network(): Promise<Network> {

View File

@@ -3,12 +3,11 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { type Layout } from '@jsonforms/core';
import { Resource } from '@unraid/shared/graphql.model.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { DataSlice } from '@unraid/shared/jsonforms/settings.js';
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { GraphQLJSON } from 'graphql-scalars';
import { AuthActionVerb, AuthPossession } from 'nest-authz';
import { EVENTS } from '../helper/nest-tokens.js';
import { ConnectSettingsService } from './connect-settings.service.js';
@@ -62,9 +61,8 @@ export class ConnectSettingsResolver {
@Query(() => RemoteAccess)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.CONNECT,
possession: AuthPossession.ANY,
})
public async remoteAccess(): Promise<RemoteAccess> {
return this.connectSettingsService.dynamicRemoteAccessSettings();
@@ -72,9 +70,8 @@ export class ConnectSettingsResolver {
@Mutation(() => ConnectSettingsValues)
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.CONFIG,
possession: AuthPossession.ANY,
})
public async updateApiSettings(@Args('input') settings: ConnectSettingsInput) {
this.logger.verbose(`Attempting to update API settings: ${JSON.stringify(settings, null, 2)}`);
@@ -92,9 +89,8 @@ export class ConnectSettingsResolver {
@Mutation(() => Boolean)
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.CONNECT,
possession: AuthPossession.ANY,
})
public async connectSignIn(@Args('input') input: ConnectSignInInput): Promise<boolean> {
return this.connectSettingsService.signIn(input);
@@ -102,9 +98,8 @@ export class ConnectSettingsResolver {
@Mutation(() => Boolean)
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.CONNECT,
possession: AuthPossession.ANY,
})
public async connectSignOut() {
this.eventEmitter.emit(EVENTS.LOGOUT, { reason: 'Manual Sign Out Using API' });
@@ -113,9 +108,8 @@ export class ConnectSettingsResolver {
@Mutation(() => Boolean)
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.CONNECT,
possession: AuthPossession.ANY,
})
public async setupRemoteAccess(@Args('input') input: SetupRemoteAccessInput): Promise<boolean> {
await this.connectSettingsService.syncSettings({
@@ -128,9 +122,8 @@ export class ConnectSettingsResolver {
@Mutation(() => Boolean)
@UsePermissions({
action: AuthActionVerb.UPDATE,
action: AuthAction.UPDATE_ANY,
resource: Resource.CONNECT__REMOTE_ACCESS,
possession: AuthPossession.ANY,
})
public async enableDynamicRemoteAccess(
@Args('input') dynamicRemoteAccessInput: EnableDynamicRemoteAccessInput

View File

@@ -2,10 +2,8 @@ import { Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Query, ResolveField, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
@@ -19,9 +17,8 @@ export class ConnectResolver {
@Query(() => Connect)
@UsePermissions({
action: AuthActionVerb.READ,
action: AuthAction.READ_ANY,
resource: Resource.CONNECT,
possession: AuthPossession.ANY,
})
public connect(): Connect {
return {

View File

@@ -0,0 +1,107 @@
// This file contains only the enum definitions without any NestJS dependencies
// Safe to import in both frontend and backend
// Define our own AuthAction enum with matching keys and values
// This ensures GraphQL schema and runtime values are identical
export enum AuthAction {
CREATE_ANY = 'CREATE_ANY',
CREATE_OWN = 'CREATE_OWN',
READ_ANY = 'READ_ANY',
READ_OWN = 'READ_OWN',
UPDATE_ANY = 'UPDATE_ANY',
UPDATE_OWN = 'UPDATE_OWN',
DELETE_ANY = 'DELETE_ANY',
DELETE_OWN = 'DELETE_OWN',
}
// Define Resource enum
export enum Resource {
/** Activation code management and validation */
ACTIVATION_CODE = 'ACTIVATION_CODE',
/** API key management and administration */
API_KEY = 'API_KEY',
/** Array operations and disk management */
ARRAY = 'ARRAY',
/** Cloud storage and backup services */
CLOUD = 'CLOUD',
/** System configuration and settings */
CONFIG = 'CONFIG',
/** Unraid Connect service management */
CONNECT = 'CONNECT',
/** Remote access functionality for Connect */
CONNECT__REMOTE_ACCESS = 'CONNECT__REMOTE_ACCESS',
/** System customization and theming */
CUSTOMIZATIONS = 'CUSTOMIZATIONS',
/** Dashboard and system overview */
DASHBOARD = 'DASHBOARD',
/** Individual disk operations and management */
DISK = 'DISK',
/** Display and UI settings */
DISPLAY = 'DISPLAY',
/** Docker container management */
DOCKER = 'DOCKER',
/** Flash drive operations and settings */
FLASH = 'FLASH',
/** System information and status */
INFO = 'INFO',
/** System logs and logging */
LOGS = 'LOGS',
/** Current user profile and settings */
ME = 'ME',
/** Network configuration and management */
NETWORK = 'NETWORK',
/** System notifications and alerts */
NOTIFICATIONS = 'NOTIFICATIONS',
/** Online services and connectivity */
ONLINE = 'ONLINE',
/** Operating system operations and updates */
OS = 'OS',
/** System ownership and licensing */
OWNER = 'OWNER',
/** Permission management and administration */
PERMISSION = 'PERMISSION',
/** System registration and activation */
REGISTRATION = 'REGISTRATION',
/** My Servers management and configuration */
SERVERS = 'SERVERS',
/** System services and daemons */
SERVICES = 'SERVICES',
/** File share management */
SHARE = 'SHARE',
/** System variables and environment */
VARS = 'VARS',
/** Virtual machine management */
VMS = 'VMS',
/** Welcome and onboarding features */
WELCOME = 'WELCOME',
}
export enum Role {
/** Full administrative access to all resources */
ADMIN = 'ADMIN',
/** Read access to all resources with remote access management */
CONNECT = 'CONNECT',
/** Basic read access to user profile only */
GUEST = 'GUEST',
/** Read-only access to all resources */
VIEWER = 'VIEWER',
}
// Simple interfaces without decorators
export interface ApiKey {
id: string;
name: string;
description?: string;
roles?: Role[];
permissions?: Permission[];
createdAt: string;
}
export interface ApiKeyWithSecret extends ApiKey {
key: string;
}
export interface Permission {
resource: Resource;
actions: AuthAction[];
}

View File

@@ -1,49 +1,16 @@
// This file is for backend use only - contains NestJS decorators
import { Field, InterfaceType, registerEnumType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
import { PrefixedID } from './prefixed-id-scalar.js';
import { AuthActionVerb } from 'nest-authz';
// Register enums
export enum Resource {
ACTIVATION_CODE = 'ACTIVATION_CODE',
API_KEY = 'API_KEY',
ARRAY = 'ARRAY',
CLOUD = 'CLOUD',
CONFIG = 'CONFIG',
CONNECT = 'CONNECT',
CONNECT__REMOTE_ACCESS = 'CONNECT__REMOTE_ACCESS',
CUSTOMIZATIONS = 'CUSTOMIZATIONS',
DASHBOARD = 'DASHBOARD',
DISK = 'DISK',
DISPLAY = 'DISPLAY',
DOCKER = 'DOCKER',
FLASH = 'FLASH',
INFO = 'INFO',
LOGS = 'LOGS',
ME = 'ME',
NETWORK = 'NETWORK',
NOTIFICATIONS = 'NOTIFICATIONS',
ONLINE = 'ONLINE',
OS = 'OS',
OWNER = 'OWNER',
PERMISSION = 'PERMISSION',
REGISTRATION = 'REGISTRATION',
SERVERS = 'SERVERS',
SERVICES = 'SERVICES',
SHARE = 'SHARE',
VARS = 'VARS',
VMS = 'VMS',
WELCOME = 'WELCOME',
}
// Import enums from the shared file
import { AuthAction, Resource, Role } from './graphql-enums.js';
export enum Role {
ADMIN = 'ADMIN',
USER = 'USER',
CONNECT = 'CONNECT',
GUEST = 'GUEST',
}
// Re-export for convenience
export { AuthAction, Resource, Role };
// Re-export types from graphql-enums
export type { ApiKey, ApiKeyWithSecret, Permission } from './graphql-enums.js';
@InterfaceType()
export class Node {
@@ -61,22 +28,51 @@ registerEnumType(Resource, {
registerEnumType(Role, {
name: 'Role',
description: 'Available roles for API keys and users',
valuesMap: {
ADMIN: {
description: 'Full administrative access to all resources',
},
CONNECT: {
description: 'Internal Role for Unraid Connect',
},
GUEST: {
description: 'Basic read access to user profile only',
},
VIEWER: {
description: 'Read-only access to all resources',
},
},
});
export interface ApiKey {
id: string;
name: string;
description?: string;
roles?: Role[];
permissions?: Permission[];
createdAt: string;
}
// Register AuthAction enum for GraphQL
registerEnumType(AuthAction, {
name: 'AuthAction',
description: 'Authentication actions with possession (e.g., create:any, read:own)',
valuesMap: {
CREATE_ANY: {
description: 'Create any resource',
},
CREATE_OWN: {
description: 'Create own resource',
},
READ_ANY: {
description: 'Read any resource',
},
READ_OWN: {
description: 'Read own resource',
},
UPDATE_ANY: {
description: 'Update any resource',
},
UPDATE_OWN: {
description: 'Update own resource',
},
DELETE_ANY: {
description: 'Delete any resource',
},
DELETE_OWN: {
description: 'Delete own resource',
},
},
});
export interface ApiKeyWithSecret extends ApiKey {
key: string;
}
export interface Permission {
resource: Resource;
actions: AuthActionVerb[];
}

View File

@@ -3,4 +3,5 @@ export { SocketConfigService } from './services/socket-config.service.js';
export * from './graphql.model.js';
export * from './tokens.js';
export * from './use-permissions.directive.js';
export * from './util/permissions.js';
export type { InternalGraphQLClientFactory } from './types/internal-graphql-client.factory.js';

View File

@@ -1,6 +1,10 @@
import { ApiKey, ApiKeyWithSecret, Permission } from '../graphql.model.js';
import { Role } from '../graphql.model.js';
import { AuthActionVerb } from 'nest-authz';
import { ApiKey, Permission } from '../graphql.model.js';
import { Role, AuthAction, Resource } from '../graphql.model.js';
/**
* Input type for creating API key permissions
*/
export type CreatePermissionsInput = Permission[] | Array<{ resource: Resource; actions: AuthAction[] }>;
export interface ApiKeyService {
/**
@@ -9,19 +13,20 @@ export interface ApiKeyService {
findById(id: string): Promise<ApiKey | null>;
/**
* Find an API key by its ID, including the secret key
* Find an API key by its ID
* Note: This returns ApiKey without the secret for security
*/
findByIdWithSecret(id: string): ApiKeyWithSecret | null;
findByIdWithSecret(id: string): ApiKey | null;
/**
* Find an API key by a specific field
*/
findByField(field: keyof ApiKeyWithSecret, value: string): ApiKeyWithSecret | null;
findByField(field: keyof ApiKey, value: string): ApiKey | null;
/**
* Find an API key by its secret key
*/
findByKey(key: string): ApiKeyWithSecret | null;
findByKey(key: string): ApiKey | null;
/**
* Create a new API key
@@ -30,9 +35,9 @@ export interface ApiKeyService {
name: string;
description?: string;
roles?: Role[];
permissions?: Permission[] | { resource: string; actions: AuthActionVerb[] }[];
permissions?: CreatePermissionsInput;
overwrite?: boolean;
}): Promise<ApiKeyWithSecret>;
}): Promise<ApiKey>;
/**
* Get all valid permissions that can be assigned to an API key

View File

@@ -0,0 +1,314 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { AuthAction, Resource } from './graphql-enums.js';
import { UsePermissions } from './use-permissions.directive.js';
// Mock NestJS dependencies
vi.mock('nest-authz', () => ({
UsePermissions: vi.fn(() => vi.fn()),
}));
vi.mock('@nestjs/graphql', () => ({
Directive: vi.fn(() => vi.fn()),
}));
beforeEach(() => {
vi.clearAllMocks();
});
describe('UsePermissions Directive', () => {
describe('Resource Validation', () => {
it('should accept valid Resource enum values', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.API_KEY,
});
expect(() => {
decorator({}, 'testMethod', {});
}).not.toThrow();
});
it('should accept valid Resource enum values', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.API_KEY,
});
expect(() => {
decorator({}, 'testMethod', {});
}).not.toThrow();
});
it('should accept Resource enum values', () => {
const decorator = UsePermissions({
action: AuthAction.CREATE_ANY,
resource: Resource.ACTIVATION_CODE,
});
expect(() => {
decorator({}, 'testMethod', {});
}).not.toThrow();
});
it('should reject invalid resource values at runtime', () => {
// TypeScript prevents this at compile time, but we can test runtime validation
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: 'INVALID_RESOURCE' as any as Resource,
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow(/Invalid Resource enum value/);
});
it('should reject typos in resource names at runtime', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: 'API_KEYS' as any as Resource, // typo: should be API_KEY
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow(/Invalid Resource enum value: API_KEYS/);
});
it('should provide helpful error message listing valid resources', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: 'INVALID' as any as Resource,
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow(/Must be one of:/);
});
});
describe('SDL Injection Protection', () => {
it('should reject resources with special characters', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: 'API_KEY", malicious: "true' as any as Resource,
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow(/Invalid Resource enum value/);
});
it('should reject resources with GraphQL directive injection attempts', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: 'API_KEY") @skipAuth' as any as Resource,
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow(/Invalid Resource enum value/);
});
it('should reject resources with invalid lowercase names', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: 'api_key' as any as Resource, // lowercase not matching enum
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow(/Invalid Resource enum value/);
});
it('should validate SDL escape function rejects invalid characters', () => {
// This tests the escapeForSDL function indirectly
const decorator = UsePermissions({
action: AuthAction.READ_ANY, // Use the proper enum value
resource: Resource.API_KEY,
});
// The action should pass validation
expect(() => {
decorator({}, 'testMethod', {});
}).not.toThrow();
});
});
describe('Action Validation', () => {
it('should accept valid AuthAction enum values', () => {
const decorator = UsePermissions({
action: AuthAction.CREATE_OWN,
resource: Resource.API_KEY,
});
expect(() => {
decorator({}, 'testMethod', {});
}).not.toThrow();
});
it('should reject invalid AuthAction values', () => {
const decorator = UsePermissions({
action: 'invalid:action' as AuthAction,
resource: Resource.API_KEY,
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow('Invalid AuthAction enum value: invalid:action');
});
it('should reject invalid action combinations in old format', () => {
const decorator = UsePermissions({
action: 'invalid' as any,
possession: 'any' as any,
resource: Resource.API_KEY,
} as any);
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow('Invalid AuthAction enum value: invalid');
});
it('should provide helpful error message listing valid actions', () => {
const decorator = UsePermissions({
action: 'bad:action' as AuthAction,
resource: Resource.API_KEY,
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow(/Must be one of:/);
});
});
describe('Legacy Format Support', () => {
it('should support old format with separate verb and possession', () => {
const decorator = UsePermissions({
action: 'CREATE' as any,
possession: 'ANY' as any,
resource: Resource.API_KEY,
} as any);
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow('Invalid AuthAction enum value: CREATE');
});
it('should normalize verb and possession to AuthAction', () => {
const decorator = UsePermissions({
action: 'READ' as any,
possession: 'OWN' as any,
resource: Resource.DASHBOARD,
} as any);
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow('Invalid AuthAction enum value: READ');
});
});
describe('Error Message Clarity', () => {
it('should provide clear error for invalid resource', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: 'WRONG' as any as Resource,
});
try {
decorator({}, 'testMethod', {});
} catch (error: any) {
expect(error.message).toContain('Invalid Resource enum value: WRONG');
expect(error.message).toContain('Must be one of:');
expect(error.message).toContain('API_KEY');
expect(error.message).toContain('DASHBOARD');
}
});
it('should provide clear error for invalid action', () => {
const decorator = UsePermissions({
action: 'wrong:action' as AuthAction,
resource: Resource.API_KEY,
});
try {
decorator({}, 'testMethod', {});
} catch (error: any) {
expect(error.message).toContain('Invalid AuthAction enum value: wrong:action');
expect(error.message).toContain('Must be one of:');
expect(error.message).toContain('CREATE_ANY');
expect(error.message).toContain('READ_OWN');
}
});
it('should provide clear error for invalid action combination', () => {
const decorator = UsePermissions({
action: 'invalid' as any,
possession: 'wrong' as any,
resource: Resource.API_KEY,
} as any);
try {
decorator({}, 'testMethod', {});
} catch (error: any) {
expect(error.message).toContain('Invalid AuthAction enum value: invalid');
expect(error.message).toContain('Must be one of:');
}
});
});
describe('Security Edge Cases', () => {
it('should handle resources with double underscores', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.CONNECT__REMOTE_ACCESS,
});
expect(() => {
decorator({}, 'testMethod', {});
}).not.toThrow();
});
it('should reject null or undefined resources', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: null as any,
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow();
});
it('should reject empty string resources', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: '' as any as Resource,
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow(/Invalid Resource enum value: /);
});
it('should reject resources with newlines', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: 'API_KEY\n@skipAuth' as any as Resource,
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow(/Invalid Resource enum value/);
});
it('should reject resources with backslashes', () => {
const decorator = UsePermissions({
action: AuthAction.READ_ANY,
resource: 'API_KEY\\", another: "value' as any as Resource,
});
expect(() => {
decorator({}, 'testMethod', {});
}).toThrow(/Invalid Resource enum value/);
});
});
});

View File

@@ -4,80 +4,123 @@ import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils';
import {
DirectiveLocation,
GraphQLDirective,
GraphQLEnumType,
GraphQLSchema,
GraphQLString,
} from 'graphql';
import { AuthActionVerb, AuthPossession, UsePermissions as NestAuthzUsePermissions } from 'nest-authz';
import { UsePermissions as NestAuthzUsePermissions } from 'nest-authz';
// Import from graphql-enums.js to avoid NestJS dependencies
import { AuthAction, Resource } from './graphql-enums.js';
// Re-export the types from nest-authz
export { AuthActionVerb, AuthPossession };
// Re-export the types for convenience
export { AuthAction, Resource };
const buildGraphQLEnum = (
enumObj: Record<string, string | number>,
name: string,
description: string
) => {
const values = Object.entries(enumObj)
.filter(([key]) => isNaN(Number(key)))
.reduce(
(acc, [key]) => {
acc[key] = { value: key };
return acc;
},
{} as Record<string, { value: string }>
);
return new GraphQLEnumType({ name, description, values });
};
// Create GraphQL enum types for auth action verbs and possessions
const AuthActionVerbEnum = buildGraphQLEnum(
AuthActionVerb,
'AuthActionVerb',
'Available authentication action verbs'
);
const AuthPossessionEnum = buildGraphQLEnum(
AuthPossession,
'AuthPossession',
'Available authentication possession types'
);
// Create the auth directive
/**
* GraphQL Directive Definition for @usePermissions
*
* IMPORTANT: GraphQL directives MUST use scalar types (String, Int, Boolean) for their arguments
* according to the GraphQL specification. This is why action and resource are defined as GraphQLString
* even though we use enum types in TypeScript.
*
* Type safety is enforced at:
* 1. Compile-time: TypeScript decorator requires AuthAction and Resource enum types
* 2. Runtime: The decorator validates that string values match valid enum values
*
* The generated schema will show:
* directive @usePermissions(action: String, resource: String) on FIELD_DEFINITION
*
* But the actual usage in code requires proper enum types for type safety.
*/
export const UsePermissionsDirective = new GraphQLDirective({
name: 'usePermissions',
description: 'Directive to document required permissions for fields',
locations: [DirectiveLocation.FIELD_DEFINITION],
args: {
action: {
type: AuthActionVerbEnum,
description: 'The action verb required for access',
type: GraphQLString,
description: 'The action required for access (must be a valid AuthAction enum value)',
},
resource: {
type: GraphQLString,
description: 'The resource required for access',
},
possession: {
type: AuthPossessionEnum,
description: 'The possession type required for access',
description: 'The resource required for access (must be a valid Resource enum value)',
},
},
});
// Create a decorator that combines both the GraphQL directive and UsePermissions
export const UsePermissions = (permissions: {
action: AuthActionVerb;
resource: string;
possession: AuthPossession;
}) => {
return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
// Apply UsePermissions for actual authorization
NestAuthzUsePermissions(permissions)(target, propertyKey, descriptor);
/**
* Permissions interface for the UsePermissions decorator
*/
export interface Permissions {
action: AuthAction;
resource: Resource;
}
// Apply GraphQL directive using NestJS's @Directive decorator
/**
* UsePermissions Decorator
*
* Applies permission-based authorization to GraphQL resolvers and adds schema documentation.
*
* @example
* ```typescript
* @Query(() => [User])
* @UsePermissions({
* action: AuthAction.READ_ANY,
* resource: Resource.USERS
* })
* async getUsers() { ... }
* ```
*
* The decorator:
* 1. Enforces TypeScript type safety with enum types
* 2. Validates enum values at runtime
* 3. Applies nest-authz authorization checks
* 4. Adds @usePermissions directive to GraphQL schema
*
* Note: While the GraphQL schema shows String types for the directive,
* TypeScript ensures only valid enum values can be used.
*/
export function UsePermissions(permissions: Permissions): MethodDecorator {
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
const finalAction = permissions.action;
const finalResource = permissions.resource;
// Runtime validation as a safety check
if (!Object.values(AuthAction).includes(finalAction)) {
throw new Error(
`Invalid AuthAction enum value: ${finalAction}. Must be one of: ${Object.values(AuthAction).join(', ')}`
);
}
if (!Object.values(Resource).includes(finalResource)) {
throw new Error(
`Invalid Resource enum value: ${finalResource}. Must be one of: ${Object.values(Resource).join(', ')}`
);
}
// Escape values for safe SDL injection
const escapeForSDL = (value: string): string => {
// Validate that the value only contains expected characters
// Allow letters, digits, underscores, colons, and hyphens (for actions like "READ_ANY", plugin-style values)
const allowedPattern = /^[A-Za-z0-9_:-]+$/;
if (!allowedPattern.test(value)) {
throw new Error(
`Invalid characters in permission value: "${value}". Only letters, digits, underscores, colons, and hyphens are allowed.`
);
}
// Escape special characters for GraphQL string literals
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
};
const escapedAction = escapeForSDL(finalAction);
const escapedResource = escapeForSDL(finalResource);
// Apply UsePermissions for actual authorization
NestAuthzUsePermissions({ action: finalAction, resource: finalResource })(target, propertyKey, descriptor);
// Apply GraphQL directive using NestJS's @Directive decorator with escaped values
Directive(
`@usePermissions(action: ${permissions.action.toUpperCase()}, resource: "${permissions.resource}", possession: ${permissions.possession.toUpperCase()})`
`@usePermissions(action: "${escapedAction}", resource: "${escapedResource}")`
)(target, propertyKey, descriptor);
return descriptor;
@@ -93,10 +136,9 @@ export function usePermissionsSchemaTransformer(schema: GraphQLSchema) {
const {
action: actionValue,
resource: resourceValue,
possession: possessionValue,
} = usePermissionsDirective;
if (!actionValue || !resourceValue || !possessionValue) {
if (!actionValue || !resourceValue) {
console.warn(
`UsePermissions directive on ${typeName}.${fieldName} is missing required arguments.`
);
@@ -108,8 +150,7 @@ export function usePermissionsSchemaTransformer(schema: GraphQLSchema) {
#### Required Permissions:
- Action: **${actionValue}**
- Resource: **${resourceValue}**
- Possession: **${possessionValue}**`;
- Resource: **${resourceValue}**`;
const descriptionDoc = fieldConfig.description
? `
@@ -123,4 +164,4 @@ ${fieldConfig.description}`
return fieldConfig;
},
});
}
}

View File

@@ -0,0 +1,221 @@
import { describe, it, expect } from 'vitest';
import {
parseActionToAuthAction,
parseResourceToEnum,
parseRoleToEnum,
convertScopesToPermissions,
convertPermissionsToScopes,
normalizeLegacyAction
} from '../permissions.js';
import { AuthAction, Resource, Role } from '../../graphql-enums.js';
describe('permissions utilities', () => {
describe('parseActionToAuthAction', () => {
it('handles valid AuthAction enum values', () => {
expect(parseActionToAuthAction('READ_ANY')).toBe(AuthAction.READ_ANY);
expect(parseActionToAuthAction('CREATE_OWN')).toBe(AuthAction.CREATE_OWN);
expect(parseActionToAuthAction('UPDATE_ANY')).toBe(AuthAction.UPDATE_ANY);
expect(parseActionToAuthAction('DELETE_OWN')).toBe(AuthAction.DELETE_OWN);
});
it('handles legacy colon format', () => {
expect(parseActionToAuthAction('read:any')).toBe(AuthAction.READ_ANY);
expect(parseActionToAuthAction('create:own')).toBe(AuthAction.CREATE_OWN);
expect(parseActionToAuthAction('update:any')).toBe(AuthAction.UPDATE_ANY);
expect(parseActionToAuthAction('delete:own')).toBe(AuthAction.DELETE_OWN);
});
it('handles simple verbs with default possession', () => {
expect(parseActionToAuthAction('read')).toBe(AuthAction.READ_ANY);
expect(parseActionToAuthAction('create')).toBe(AuthAction.CREATE_ANY);
expect(parseActionToAuthAction('update')).toBe(AuthAction.UPDATE_ANY);
expect(parseActionToAuthAction('delete')).toBe(AuthAction.DELETE_ANY);
});
it('handles simple verbs with OWN as default', () => {
expect(parseActionToAuthAction('read', 'OWN')).toBe(AuthAction.READ_OWN);
expect(parseActionToAuthAction('create', 'OWN')).toBe(AuthAction.CREATE_OWN);
expect(parseActionToAuthAction('update', 'OWN')).toBe(AuthAction.UPDATE_OWN);
expect(parseActionToAuthAction('delete', 'OWN')).toBe(AuthAction.DELETE_OWN);
});
it('handles mixed case input', () => {
expect(parseActionToAuthAction('Read')).toBe(AuthAction.READ_ANY);
expect(parseActionToAuthAction('CREATE')).toBe(AuthAction.CREATE_ANY);
expect(parseActionToAuthAction('Update:Any')).toBe(AuthAction.UPDATE_ANY);
expect(parseActionToAuthAction('DELETE:OWN')).toBe(AuthAction.DELETE_OWN);
});
it('handles null and undefined', () => {
expect(parseActionToAuthAction(null)).toBe(null);
expect(parseActionToAuthAction(undefined)).toBe(null);
expect(parseActionToAuthAction('')).toBe(null);
});
it('returns null for invalid actions', () => {
expect(parseActionToAuthAction('invalid')).toBe(null);
expect(parseActionToAuthAction('read:invalid')).toBe(null);
expect(parseActionToAuthAction('invalid:any')).toBe(null);
});
it('ensures backward compatibility for old API keys', () => {
// Old API keys might use these formats
expect(parseActionToAuthAction('read')).toBe(AuthAction.READ_ANY);
expect(parseActionToAuthAction('write')).toBe(null); // 'write' is not a valid verb
expect(parseActionToAuthAction('create')).toBe(AuthAction.CREATE_ANY);
expect(parseActionToAuthAction('update')).toBe(AuthAction.UPDATE_ANY);
expect(parseActionToAuthAction('delete')).toBe(AuthAction.DELETE_ANY);
});
});
describe('parseResourceToEnum', () => {
it('parses valid resources', () => {
expect(parseResourceToEnum('DOCKER')).toBe(Resource.DOCKER);
expect(parseResourceToEnum('API_KEY')).toBe(Resource.API_KEY);
expect(parseResourceToEnum('ARRAY')).toBe(Resource.ARRAY);
});
it('handles case insensitive input', () => {
expect(parseResourceToEnum('docker')).toBe(Resource.DOCKER);
expect(parseResourceToEnum('Docker')).toBe(Resource.DOCKER);
expect(parseResourceToEnum('DOCKER')).toBe(Resource.DOCKER);
});
it('returns null for invalid resources', () => {
expect(parseResourceToEnum('invalid')).toBe(null);
expect(parseResourceToEnum('')).toBe(null);
});
});
describe('parseRoleToEnum', () => {
it('parses valid roles', () => {
expect(parseRoleToEnum('ADMIN')).toBe(Role.ADMIN);
expect(parseRoleToEnum('VIEWER')).toBe(Role.VIEWER);
expect(parseRoleToEnum('CONNECT')).toBe(Role.CONNECT);
expect(parseRoleToEnum('GUEST')).toBe(Role.GUEST);
});
it('handles case insensitive input', () => {
expect(parseRoleToEnum('admin')).toBe(Role.ADMIN);
expect(parseRoleToEnum('Admin')).toBe(Role.ADMIN);
expect(parseRoleToEnum('ADMIN')).toBe(Role.ADMIN);
});
it('returns null for invalid roles', () => {
expect(parseRoleToEnum('invalid')).toBe(null);
expect(parseRoleToEnum('')).toBe(null);
});
});
describe('convertScopesToPermissions', () => {
it('converts role scopes', () => {
const result = convertScopesToPermissions(['role:admin', 'role:viewer']);
expect(result.roles).toEqual([Role.ADMIN, Role.VIEWER]);
expect(result.permissions).toEqual([]);
});
it('converts permission scopes with actions', () => {
const result = convertScopesToPermissions([
'docker:read:any',
'docker:update:any',
'vms:create:own'
]);
expect(result.roles).toEqual([]);
expect(result.permissions).toHaveLength(2);
const dockerPerm = result.permissions.find(p => p.resource === Resource.DOCKER);
expect(dockerPerm?.actions).toContain(AuthAction.READ_ANY);
expect(dockerPerm?.actions).toContain(AuthAction.UPDATE_ANY);
const vmsPerm = result.permissions.find(p => p.resource === Resource.VMS);
expect(vmsPerm?.actions).toEqual([AuthAction.CREATE_OWN]);
});
it('handles wildcard actions', () => {
const result = convertScopesToPermissions(['docker:*']);
expect(result.permissions).toHaveLength(1);
expect(result.permissions[0].resource).toBe(Resource.DOCKER);
expect(result.permissions[0].actions).toContain(AuthAction.CREATE_ANY);
expect(result.permissions[0].actions).toContain(AuthAction.READ_ANY);
expect(result.permissions[0].actions).toContain(AuthAction.UPDATE_ANY);
expect(result.permissions[0].actions).toContain(AuthAction.DELETE_ANY);
});
it('merges permissions for same resource', () => {
const result = convertScopesToPermissions([
'docker:read:any',
'docker:update:any'
]);
expect(result.permissions).toHaveLength(1);
expect(result.permissions[0].resource).toBe(Resource.DOCKER);
expect(result.permissions[0].actions).toHaveLength(2);
expect(result.permissions[0].actions).toContain(AuthAction.READ_ANY);
expect(result.permissions[0].actions).toContain(AuthAction.UPDATE_ANY);
});
it('handles legacy simple verb format', () => {
const result = convertScopesToPermissions([
'docker:read',
'vms:create',
'array:update'
]);
expect(result.permissions).toHaveLength(3);
const dockerPerm = result.permissions.find(p => p.resource === Resource.DOCKER);
expect(dockerPerm?.actions).toEqual([AuthAction.READ_ANY]);
const vmsPerm = result.permissions.find(p => p.resource === Resource.VMS);
expect(vmsPerm?.actions).toEqual([AuthAction.CREATE_ANY]);
const arrayPerm = result.permissions.find(p => p.resource === Resource.ARRAY);
expect(arrayPerm?.actions).toEqual([AuthAction.UPDATE_ANY]);
});
});
describe('convertPermissionsToScopes', () => {
it('converts roles to scopes', () => {
const scopes = convertPermissionsToScopes([], [Role.ADMIN, Role.VIEWER]);
expect(scopes).toContain('role:admin');
expect(scopes).toContain('role:viewer');
});
it('converts permissions to scopes', () => {
const scopes = convertPermissionsToScopes([
{
resource: Resource.DOCKER,
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY]
},
{
resource: Resource.VMS,
actions: [AuthAction.CREATE_OWN]
}
]);
expect(scopes).toContain('docker:read_any');
expect(scopes).toContain('docker:update_any');
expect(scopes).toContain('vms:create_own');
});
});
describe('normalizeLegacyAction', () => {
it('handles simple verbs', () => {
expect(normalizeLegacyAction('create')).toBe(AuthAction.CREATE_ANY);
expect(normalizeLegacyAction('read')).toBe(AuthAction.READ_ANY);
expect(normalizeLegacyAction('update')).toBe(AuthAction.UPDATE_ANY);
expect(normalizeLegacyAction('delete')).toBe(AuthAction.DELETE_ANY);
});
it('handles uppercase with underscore', () => {
expect(normalizeLegacyAction('CREATE_ANY')).toBe(AuthAction.CREATE_ANY);
expect(normalizeLegacyAction('READ_OWN')).toBe(AuthAction.READ_OWN);
});
it('handles lowercase with colon', () => {
expect(normalizeLegacyAction('create:any')).toBe(AuthAction.CREATE_ANY);
expect(normalizeLegacyAction('read:own')).toBe(AuthAction.READ_OWN);
});
it('returns null for invalid actions', () => {
expect(normalizeLegacyAction('invalid')).toBe(null);
});
});
});

View File

@@ -0,0 +1,84 @@
import { AuthAction, Resource, Role } from '../graphql-enums.js';
import { convertScopesToPermissions } from './permissions.js';
import { describe, expect, it } from 'vitest';
describe('convertScopesToPermissions', () => {
it('should correctly handle actions with colons like read:any', () => {
const scopes = [
'API_KEY:read:any',
'DASHBOARD:create:own',
'NETWORK:update:any'
];
const result = convertScopesToPermissions(scopes);
expect(result.permissions).toHaveLength(3);
const apiKeyPerm = result.permissions.find(p => p.resource === Resource.API_KEY);
expect(apiKeyPerm).toBeDefined();
expect(apiKeyPerm?.actions).toContain(AuthAction.READ_ANY);
const dashboardPerm = result.permissions.find(p => p.resource === Resource.DASHBOARD);
expect(dashboardPerm).toBeDefined();
expect(dashboardPerm?.actions).toContain(AuthAction.CREATE_OWN);
const networkPerm = result.permissions.find(p => p.resource === Resource.NETWORK);
expect(networkPerm).toBeDefined();
expect(networkPerm?.actions).toContain(AuthAction.UPDATE_ANY);
});
it('should handle wildcard actions', () => {
const scopes = ['DOCKER:*'];
const result = convertScopesToPermissions(scopes);
expect(result.permissions).toHaveLength(1);
const dockerPerm = result.permissions[0];
expect(dockerPerm.resource).toBe(Resource.DOCKER);
expect(dockerPerm.actions).toContain(AuthAction.CREATE_ANY);
expect(dockerPerm.actions).toContain(AuthAction.READ_ANY);
expect(dockerPerm.actions).toContain(AuthAction.UPDATE_ANY);
expect(dockerPerm.actions).toContain(AuthAction.DELETE_ANY);
});
it('should handle role scopes', () => {
const scopes = ['role:ADMIN', 'role:VIEWER'];
const result = convertScopesToPermissions(scopes);
expect(result.roles).toHaveLength(2);
expect(result.roles).toContain(Role.ADMIN);
expect(result.roles).toContain(Role.VIEWER);
expect(result.permissions).toHaveLength(0);
});
it('should merge permissions for the same resource', () => {
const scopes = [
'VMS:read:any',
'VMS:update:any'
];
const result = convertScopesToPermissions(scopes);
expect(result.permissions).toHaveLength(1);
const vmsPerm = result.permissions[0];
expect(vmsPerm.resource).toBe(Resource.VMS);
expect(vmsPerm.actions).toHaveLength(2);
expect(vmsPerm.actions).toContain(AuthAction.READ_ANY);
expect(vmsPerm.actions).toContain(AuthAction.UPDATE_ANY);
});
it('should handle invalid scope formats gracefully', () => {
const scopes = [
'INVALID_SCOPE', // No colon
':action', // Empty resource
'RESOURCE:', // Empty action
'UNKNOWN:read:any' // Unknown resource
];
const result = convertScopesToPermissions(scopes);
expect(result.permissions).toHaveLength(0);
expect(result.roles).toHaveLength(0);
});
});

View File

@@ -0,0 +1,234 @@
import { describe, expect, it } from 'vitest';
import { AuthAction } from '../graphql-enums.js';
import { normalizeLegacyAction, normalizeLegacyActions, parseActionToAuthAction } from './permissions.js';
describe('normalizeLegacyAction', () => {
describe('simple verb format (legacy)', () => {
it('should convert simple verbs to AuthAction enum values', () => {
expect(normalizeLegacyAction('create')).toBe(AuthAction.CREATE_ANY);
expect(normalizeLegacyAction('read')).toBe(AuthAction.READ_ANY);
expect(normalizeLegacyAction('update')).toBe(AuthAction.UPDATE_ANY);
expect(normalizeLegacyAction('delete')).toBe(AuthAction.DELETE_ANY);
});
it('should handle uppercase simple verbs', () => {
expect(normalizeLegacyAction('CREATE')).toBe(AuthAction.CREATE_ANY);
expect(normalizeLegacyAction('READ')).toBe(AuthAction.READ_ANY);
expect(normalizeLegacyAction('UPDATE')).toBe(AuthAction.UPDATE_ANY);
expect(normalizeLegacyAction('DELETE')).toBe(AuthAction.DELETE_ANY);
});
it('should handle mixed case simple verbs', () => {
expect(normalizeLegacyAction('Create')).toBe(AuthAction.CREATE_ANY);
expect(normalizeLegacyAction('Read')).toBe(AuthAction.READ_ANY);
expect(normalizeLegacyAction('Update')).toBe(AuthAction.UPDATE_ANY);
expect(normalizeLegacyAction('Delete')).toBe(AuthAction.DELETE_ANY);
});
});
describe('uppercase underscore format (GraphQL enum style)', () => {
it('should convert CREATE_ANY format to AuthAction enums', () => {
expect(normalizeLegacyAction('CREATE_ANY')).toBe(AuthAction.CREATE_ANY);
expect(normalizeLegacyAction('READ_ANY')).toBe(AuthAction.READ_ANY);
expect(normalizeLegacyAction('UPDATE_ANY')).toBe(AuthAction.UPDATE_ANY);
expect(normalizeLegacyAction('DELETE_ANY')).toBe(AuthAction.DELETE_ANY);
});
it('should convert CREATE_OWN format to AuthAction enums', () => {
expect(normalizeLegacyAction('CREATE_OWN')).toBe(AuthAction.CREATE_OWN);
expect(normalizeLegacyAction('READ_OWN')).toBe(AuthAction.READ_OWN);
expect(normalizeLegacyAction('UPDATE_OWN')).toBe(AuthAction.UPDATE_OWN);
expect(normalizeLegacyAction('DELETE_OWN')).toBe(AuthAction.DELETE_OWN);
});
it('should handle mixed case underscore format', () => {
expect(normalizeLegacyAction('Create_Any')).toBe(AuthAction.CREATE_ANY);
expect(normalizeLegacyAction('Read_Own')).toBe(AuthAction.READ_OWN);
});
});
describe('already correct format (Casbin style)', () => {
it('should convert lowercase:colon format to enums', () => {
expect(normalizeLegacyAction('create:any')).toBe(AuthAction.CREATE_ANY);
expect(normalizeLegacyAction('read:any')).toBe(AuthAction.READ_ANY);
expect(normalizeLegacyAction('update:any')).toBe(AuthAction.UPDATE_ANY);
expect(normalizeLegacyAction('delete:any')).toBe(AuthAction.DELETE_ANY);
});
it('should normalize uppercase:colon to enums', () => {
expect(normalizeLegacyAction('CREATE:ANY')).toBe(AuthAction.CREATE_ANY);
expect(normalizeLegacyAction('READ:OWN')).toBe(AuthAction.READ_OWN);
});
it('should handle :own possession correctly', () => {
expect(normalizeLegacyAction('create:own')).toBe(AuthAction.CREATE_OWN);
expect(normalizeLegacyAction('read:own')).toBe(AuthAction.READ_OWN);
expect(normalizeLegacyAction('update:own')).toBe(AuthAction.UPDATE_OWN);
expect(normalizeLegacyAction('delete:own')).toBe(AuthAction.DELETE_OWN);
});
});
describe('edge cases', () => {
it('should return null for unknown actions', () => {
expect(normalizeLegacyAction('unknown')).toBeNull();
expect(normalizeLegacyAction('UNKNOWN')).toBeNull();
expect(normalizeLegacyAction('some_other_action')).toBeNull();
});
it('should return null for empty string', () => {
expect(normalizeLegacyAction('')).toBeNull();
});
it('should return null for wildcards (not a valid AuthAction)', () => {
expect(normalizeLegacyAction('*')).toBeNull();
});
});
});
describe('integration with parseActionToAuthAction', () => {
it('should produce valid AuthAction enum values after normalization', () => {
// Test that normalized actions can be parsed to valid enum values
const testCases = [
{ input: 'create', normalized: AuthAction.CREATE_ANY, expected: AuthAction.CREATE_ANY },
{ input: 'CREATE_ANY', normalized: AuthAction.CREATE_ANY, expected: AuthAction.CREATE_ANY },
{ input: 'read:own', normalized: AuthAction.READ_OWN, expected: AuthAction.READ_OWN },
{ input: 'UPDATE_OWN', normalized: AuthAction.UPDATE_OWN, expected: AuthAction.UPDATE_OWN },
];
for (const testCase of testCases) {
const normalized = normalizeLegacyAction(testCase.input);
expect(normalized).not.toBeNull();
expect(normalized).toBe(testCase.normalized);
// Since we've asserted normalized is not null, we can safely use it
if (normalized !== null) {
const parsed = parseActionToAuthAction(normalized);
expect(parsed).toBe(testCase.expected);
}
}
});
it('should handle all AuthAction enum values', () => {
// Ensure all enum values can round-trip through normalization
const allActions = Object.values(AuthAction);
for (const action of allActions) {
// The enum value itself should normalize correctly
const normalized = normalizeLegacyAction(action);
expect(normalized).not.toBeNull();
if (normalized !== null) {
const parsed = parseActionToAuthAction(normalized);
expect(parsed).toBe(action);
}
}
});
});
describe('normalizeLegacyActions (array helper)', () => {
it('should normalize an array of mixed format actions', () => {
const mixedActions = [
'create', // Simple verb
'READ_ANY', // Uppercase underscore
'update:own', // Already correct
'DELETE', // Uppercase simple verb
'invalid_action', // Invalid action
'', // Empty string
];
const normalized = normalizeLegacyActions(mixedActions);
expect(normalized).toEqual([
AuthAction.CREATE_ANY,
AuthAction.READ_ANY,
AuthAction.UPDATE_OWN,
AuthAction.DELETE_ANY,
// invalid_action and empty string are filtered out
]);
});
it('should handle empty array', () => {
expect(normalizeLegacyActions([])).toEqual([]);
});
it('should filter out all invalid actions', () => {
const invalidActions = ['invalid', 'unknown', 'some_other'];
expect(normalizeLegacyActions(invalidActions)).toEqual([]);
});
it('should preserve all valid actions', () => {
const validActions = [
'create:any',
'read:any',
'update:any',
'delete:any',
'create:own',
'read:own',
'update:own',
'delete:own',
];
const normalized = normalizeLegacyActions(validActions);
expect(normalized).toEqual([
AuthAction.CREATE_ANY,
AuthAction.READ_ANY,
AuthAction.UPDATE_ANY,
AuthAction.DELETE_ANY,
AuthAction.CREATE_OWN,
AuthAction.READ_OWN,
AuthAction.UPDATE_OWN,
AuthAction.DELETE_OWN,
]);
});
});
describe('API key loading scenarios', () => {
it('should handle legacy simple verb format from old API keys', () => {
const legacyActions = ['create', 'read', 'update', 'delete'];
const normalized = normalizeLegacyActions(legacyActions);
expect(normalized).toEqual([
AuthAction.CREATE_ANY,
AuthAction.READ_ANY,
AuthAction.UPDATE_ANY,
AuthAction.DELETE_ANY,
]);
});
it('should handle mixed format from partially migrated API keys', () => {
const mixedActions = [
'CREATE_ANY',
'read:any',
'update',
'DELETE_OWN'
];
const normalized = normalizeLegacyActions(mixedActions);
expect(normalized).toEqual([
AuthAction.CREATE_ANY,
AuthAction.READ_ANY,
AuthAction.UPDATE_ANY,
AuthAction.DELETE_OWN,
]);
});
it('should handle current Casbin format', () => {
const currentActions = [
'create:any',
'read:any',
'update:any',
'delete:any'
];
const normalized = normalizeLegacyActions(currentActions);
expect(normalized).toEqual([
AuthAction.CREATE_ANY,
AuthAction.READ_ANY,
AuthAction.UPDATE_ANY,
AuthAction.DELETE_ANY,
]);
});
});

View File

@@ -0,0 +1,378 @@
// Import from graphql-enums to avoid NestJS dependencies
import { Resource, Role, AuthAction } from '../graphql-enums.js';
export interface ScopeConversion {
permissions: Array<{ resource: Resource; actions: AuthAction[] }>;
roles: Role[];
}
/**
* Normalize an action string to AuthAction enum value
* Handles various input formats:
* - Full AuthAction values: 'READ_ANY', 'CREATE_OWN'
* - Lowercase with colon: 'read:any', 'create:own' (legacy)
* - Simple verbs: 'read', 'create' (defaults to '_ANY')
* - Mixed case: 'Read', 'CREATE'
*
* @param action - The action string to normalize
* @param defaultPossession - Default possession if not specified ('ANY' or 'OWN')
* @returns The normalized action as AuthAction or null if invalid
*/
export function parseActionToAuthAction(action: string | null | undefined, defaultPossession: 'ANY' | 'OWN' = 'ANY'): AuthAction | null {
if (!action) return null;
// First check if it's already a valid AuthAction value
if (Object.values(AuthAction).includes(action as AuthAction)) {
return action as AuthAction;
}
// Normalize the input - handle both underscore and colon formats
let normalized = action.trim().toUpperCase();
// Convert colon format (read:any) to underscore format (READ_ANY)
if (normalized.includes(':')) {
const parts = normalized.split(':');
if (parts.length === 2) {
const [verb, possession] = parts;
// Only accept valid possessions
if (possession !== 'ANY' && possession !== 'OWN') {
return null;
}
normalized = `${verb}_${possession}`;
} else {
return null;
}
}
// Check if normalized version is valid
if (Object.values(AuthAction).includes(normalized as AuthAction)) {
return normalized as AuthAction;
}
// Handle simple verbs without possession
const simpleVerbs = ['CREATE', 'READ', 'UPDATE', 'DELETE'];
const parts = normalized.split('_');
const verb = parts[0];
// If there's already a possession part, don't add default
if (parts.length === 1 && simpleVerbs.includes(verb)) {
const withPossession = `${verb}_${defaultPossession}` as AuthAction;
if (Object.values(AuthAction).includes(withPossession)) {
return withPossession;
}
}
return null;
}
/**
* Convenience function to parse action to enum (alias for backward compatibility)
* @deprecated Use parseActionToAuthAction instead
*/
export const parseActionToEnum = parseActionToAuthAction;
/**
* Parse a resource string to Resource enum
* Handles special cases and variations
*
* @param resourceStr - The resource string to parse
* @returns The Resource enum value or null if invalid
*/
export function parseResourceToEnum(resourceStr: string): Resource | null {
const normalized = resourceStr.trim().toUpperCase();
// Direct enum lookup
const directMatch = Resource[normalized as keyof typeof Resource];
if (directMatch) {
return directMatch;
}
return null;
}
/**
* Parse a role string to Role enum
*
* @param roleStr - The role string to parse
* @returns The Role enum value or null if invalid
*/
export function parseRoleToEnum(roleStr: string): Role | null {
const normalized = roleStr.trim().toUpperCase();
const role = Role[normalized as keyof typeof Role];
return role || null;
}
/**
* Convert scope strings to permissions and roles
* Scopes can be in format:
* - "role:admin" for roles
* - "docker:read" for resource permissions
* - "docker:*" for all actions on a resource
*
* @param scopes - Array of scope strings
* @returns Object containing parsed permissions and roles
*/
export function convertScopesToPermissions(scopes: string[]): ScopeConversion {
const permissions: Array<{ resource: Resource; actions: AuthAction[] }> = [];
const roles: Role[] = [];
for (const scope of scopes) {
if (scope.startsWith('role:')) {
// Handle role scope
const roleStr = scope.substring(5);
const role = parseRoleToEnum(roleStr);
if (role) {
roles.push(role);
} else {
console.warn(`Unknown role in scope: ${scope}`);
}
} else {
// Handle permission scope - split only on first colon
const colonIndex = scope.indexOf(':');
if (colonIndex === -1) {
console.warn(`Invalid scope format (missing colon): ${scope}`);
continue;
}
const resourceStr = scope.substring(0, colonIndex);
const actionStr = scope.substring(colonIndex + 1).trim();
if (resourceStr && actionStr) {
const resource = parseResourceToEnum(resourceStr);
if (!resource) {
console.warn(`Unknown resource in scope: ${scope}`);
continue;
}
// Handle wildcard or specific action
let actions: AuthAction[];
if (actionStr === '*') {
actions = [
AuthAction.CREATE_ANY,
AuthAction.READ_ANY,
AuthAction.UPDATE_ANY,
AuthAction.DELETE_ANY
];
} else {
// Actions like "read:any" should be preserved as-is
const action = parseActionToAuthAction(actionStr);
if (action) {
actions = [action];
} else {
console.warn(`Unknown action in scope: ${scope}`);
continue;
}
}
// Merge with existing permissions for the same resource
const existing = permissions.find(p => p.resource === resource);
if (existing) {
actions.forEach(a => {
if (!existing.actions.includes(a)) {
existing.actions.push(a);
}
});
} else {
permissions.push({ resource, actions });
}
} else {
console.warn(`Invalid scope format: ${scope}`);
}
}
}
return { permissions, roles };
}
/**
* Convert permissions and roles back to scope strings
* Inverse of convertScopesToPermissions
*
* @param permissions - Array of resource/action pairs
* @param roles - Array of roles
* @returns Array of scope strings
*/
export function convertPermissionsToScopes(
permissions: Array<{ resource: Resource; actions: AuthAction[] }>,
roles: Role[] = []
): string[] {
const scopes: string[] = [];
// Add role scopes
for (const role of roles) {
scopes.push(`role:${role.toLowerCase()}`);
}
// Add permission scopes
for (const perm of permissions) {
const resourceStr = perm.resource.toLowerCase();
for (const action of perm.actions) {
const actionStr = action.toLowerCase();
scopes.push(`${resourceStr}:${actionStr}`);
}
}
return scopes;
}
/**
* Create a scope string from a role
* @param role - The role to convert
* @returns Scope string like "role:admin"
*/
export function roleToScope(role: Role | string): string {
return `role:${role.toLowerCase()}`;
}
/**
* Create a scope string from resource and action
* @param resource - The resource
* @param action - The action (can be verb, AuthAction, or wildcard)
* @returns Scope string like "docker:read" or "docker:*"
*/
export function permissionToScope(resource: Resource | string, action: string): string {
return `${resource.toLowerCase()}:${action.toLowerCase()}`;
}
/**
* Check if a scope string represents a role
* @param scope - The scope string to check
* @returns True if the scope is a role scope
*/
export function isRoleScope(scope: string): boolean {
return scope.startsWith('role:');
}
/**
* Extract the role from a role scope string
* @param scope - The scope string like "role:admin"
* @returns The role name or null if not a role scope
*/
export function getRoleFromScope(scope: string): string | null {
if (!isRoleScope(scope)) return null;
return scope.substring(5);
}
/**
* Normalize an action string to AuthAction format
* @param action - The action string to normalize
* @returns The normalized AuthAction or null if parsing fails
*/
export function normalizeAction(action: string): AuthAction | null {
return parseActionToAuthAction(action);
}
/**
* Normalize legacy action formats to AuthAction enum values
* Handles multiple formats:
* - Simple verbs: "create", "read", "update", "delete" -> AuthAction.CREATE_ANY, etc.
* - Uppercase with underscore: "CREATE_ANY", "READ_ANY" -> AuthAction.CREATE_ANY, etc.
* - Already correct: "create:any", "read:any" -> AuthAction.CREATE_ANY, etc.
*
* @param action - The action string to normalize
* @returns The normalized AuthAction enum value or null if invalid
*/
export function normalizeLegacyAction(action: string): AuthAction | null {
const actionLower = action.toLowerCase();
let normalizedString: string;
// If it's already in lowercase:colon format, use it
if (actionLower.includes(':')) {
normalizedString = actionLower;
}
// If it's in uppercase_underscore format, convert to lowercase:colon
else if (action.includes('_')) {
normalizedString = actionLower.replace('_', ':');
}
// If it's a simple verb without possession, add ":any" as default
else if (['create', 'read', 'update', 'delete'].includes(actionLower)) {
normalizedString = `${actionLower}:any`;
}
// Otherwise just use lowercase (for unknown actions)
else {
normalizedString = actionLower;
}
// Convert the normalized string to AuthAction enum
return parseActionToAuthAction(normalizedString);
}
/**
* Normalize an array of legacy action strings to AuthAction enum values
* Filters out any invalid actions that can't be normalized
*
* @param actions - Array of action strings in various formats
* @returns Array of valid AuthAction enum values
*/
export function normalizeLegacyActions(actions: string[]): AuthAction[] {
return actions
.map(action => normalizeLegacyAction(action))
.filter((action): action is AuthAction => action !== null);
}
/**
* Expand wildcard action (*) to all CRUD actions
* @returns Array of all CRUD AuthAction values
*/
export function expandWildcardAction(): AuthAction[] {
return [AuthAction.CREATE_ANY, AuthAction.READ_ANY, AuthAction.UPDATE_ANY, AuthAction.DELETE_ANY];
}
/**
* Reconcile wildcard permissions by expanding them to all resources
* @param permissionsWithSets - Map of resources to action sets, may include wildcard resource
*/
export function reconcileWildcardPermissions(permissionsWithSets: Map<Resource | '*', Set<AuthAction>>): void {
if (permissionsWithSets.has('*' as Resource | '*')) {
const wildcardActions = permissionsWithSets.get('*' as Resource | '*')!;
permissionsWithSets.delete('*' as Resource | '*');
// Apply wildcard actions to ALL resources (not just existing ones)
for (const resource of Object.values(Resource)) {
if (!permissionsWithSets.has(resource)) {
permissionsWithSets.set(resource, new Set());
}
const actionsSet = permissionsWithSets.get(resource)!;
wildcardActions.forEach((action) => actionsSet.add(action));
}
}
}
/**
* Merge permissions from source map into target map
* @param targetMap - Map to merge permissions into
* @param sourceMap - Map to merge permissions from
*/
export function mergePermissionsIntoMap(
targetMap: Map<Resource, Set<AuthAction>>,
sourceMap: Map<Resource, AuthAction[]>
): void {
for (const [resource, actions] of sourceMap) {
if (!targetMap.has(resource)) {
targetMap.set(resource, new Set());
}
const actionsSet = targetMap.get(resource)!;
actions.forEach((action) => actionsSet.add(action));
}
}
/**
* Convert permission sets to arrays, filtering out wildcards
* @param permissionsWithSets - Map of resources to action sets
* @returns Map of resources to action arrays (excludes wildcard resource)
*/
export function convertPermissionSetsToArrays(
permissionsWithSets: Map<Resource | '*', Set<AuthAction>>
): Map<Resource, AuthAction[]> {
const result = new Map<Resource, AuthAction[]>();
for (const [resource, actionsSet] of permissionsWithSets) {
if (resource !== '*') {
result.set(resource as Resource, Array.from(actionsSet));
}
}
return result;
}