mirror of
https://github.com/unraid/api.git
synced 2026-01-06 08:39:54 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
107
packages/unraid-shared/src/graphql-enums.ts
Normal file
107
packages/unraid-shared/src/graphql-enums.ts
Normal 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[];
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
314
packages/unraid-shared/src/use-permissions.directive.spec.ts
Normal file
314
packages/unraid-shared/src/use-permissions.directive.spec.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
221
packages/unraid-shared/src/util/__tests__/permissions.test.ts
Normal file
221
packages/unraid-shared/src/util/__tests__/permissions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
84
packages/unraid-shared/src/util/permissions-scopes.spec.ts
Normal file
84
packages/unraid-shared/src/util/permissions-scopes.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
234
packages/unraid-shared/src/util/permissions.spec.ts
Normal file
234
packages/unraid-shared/src/util/permissions.spec.ts
Normal 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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
378
packages/unraid-shared/src/util/permissions.ts
Normal file
378
packages/unraid-shared/src/util/permissions.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user