mirror of
https://github.com/unraid/api.git
synced 2026-05-09 08:41:12 -05: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:
@@ -156,4 +156,5 @@ Enables GraphQL playground at `http://tower.local/graphql`
|
||||
## Development Memories
|
||||
|
||||
- We are using tailwind v4 we do not need a tailwind config anymore
|
||||
- always search the internet for tailwind v4 documentation when making tailwind related style changes
|
||||
- always search the internet for tailwind v4 documentation when making tailwind related style changes
|
||||
- never run or restart the API server or web server. I will handle the lifecylce, simply wait and ask me to do this for you
|
||||
@@ -0,0 +1,100 @@
|
||||
# API Key Authorization Flow
|
||||
|
||||
This document describes the self-service API key creation flow for third-party applications.
|
||||
|
||||
## Overview
|
||||
|
||||
Applications can request API access to an Unraid server by redirecting users to a special authorization page where users can review requested permissions and create an API key with one click.
|
||||
|
||||
## Flow
|
||||
|
||||
1. **Application initiates request**: The app redirects the user to:
|
||||
|
||||
```
|
||||
https://[unraid-server]/ApiKeyAuthorize?name=MyApp&scopes=docker:read,vm:*&redirect_uri=https://myapp.com/callback&state=abc123
|
||||
```
|
||||
|
||||
2. **User authentication**: If not already logged in, the user is redirected to login first (standard Unraid auth)
|
||||
|
||||
3. **Consent screen**: User sees:
|
||||
- Application name and description
|
||||
- Requested permissions (with checkboxes to approve/deny specific scopes)
|
||||
- API key name field (pre-filled)
|
||||
- Authorize & Cancel buttons
|
||||
|
||||
4. **API key creation**: Upon authorization:
|
||||
- API key is created with approved scopes
|
||||
- Key is displayed to the user
|
||||
- If `redirect_uri` is provided, user is redirected back with the key
|
||||
|
||||
5. **Callback**: App receives the API key:
|
||||
```
|
||||
https://myapp.com/callback?api_key=xxx&state=abc123
|
||||
```
|
||||
|
||||
## Query Parameters
|
||||
|
||||
- `name` (required): Name of the requesting application
|
||||
- `description` (optional): Description of the application
|
||||
- `scopes` (required): Comma-separated list of requested scopes
|
||||
- `redirect_uri` (optional): URL to redirect after authorization
|
||||
- `state` (optional): Opaque value for maintaining state
|
||||
|
||||
## Scope Format
|
||||
|
||||
Scopes follow the pattern: `resource:action`
|
||||
|
||||
### Examples:
|
||||
|
||||
- `docker:read` - Read access to Docker
|
||||
- `vm:*` - Full access to VMs
|
||||
- `system:update` - Update access to system
|
||||
- `role:viewer` - Viewer role access
|
||||
- `role:admin` - Admin role access
|
||||
|
||||
### Available Resources:
|
||||
|
||||
- `docker`, `vm`, `system`, `share`, `user`, `network`, `disk`, etc.
|
||||
|
||||
### Available Actions:
|
||||
|
||||
- `create`, `read`, `update`, `delete` or `*` for all
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **HTTPS required**: Redirect URIs must use HTTPS (except localhost for development)
|
||||
2. **User consent**: Users explicitly approve each permission
|
||||
3. **Session-based**: Uses existing Unraid authentication session
|
||||
4. **One-time display**: API keys are shown once and must be saved securely
|
||||
|
||||
## Example Integration
|
||||
|
||||
```javascript
|
||||
// JavaScript example
|
||||
const unraidServer = 'tower.local';
|
||||
const appName = 'My Docker Manager';
|
||||
const scopes = 'docker:*,system:read';
|
||||
const redirectUri = 'https://myapp.com/unraid/callback';
|
||||
const state = generateRandomState();
|
||||
|
||||
// Store state for verification
|
||||
sessionStorage.setItem('oauth_state', state);
|
||||
|
||||
// Redirect user to authorization page
|
||||
window.location.href =
|
||||
`https://${unraidServer}/ApiKeyAuthorize?` +
|
||||
`name=${encodeURIComponent(appName)}&` +
|
||||
`scopes=${encodeURIComponent(scopes)}&` +
|
||||
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
|
||||
`state=${encodeURIComponent(state)}`;
|
||||
|
||||
// Handle callback
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const apiKey = urlParams.get('api_key');
|
||||
const returnedState = urlParams.get('state');
|
||||
|
||||
if (returnedState === sessionStorage.getItem('oauth_state')) {
|
||||
// Save API key securely
|
||||
saveApiKey(apiKey);
|
||||
}
|
||||
```
|
||||
+129
-82
@@ -4,14 +4,11 @@
|
||||
|
||||
"""Directive to document required permissions for fields"""
|
||||
directive @usePermissions(
|
||||
"""The action verb required for access"""
|
||||
action: AuthActionVerb
|
||||
"""The action required for access (must be a valid AuthAction enum value)"""
|
||||
action: String
|
||||
|
||||
"""The resource required for access"""
|
||||
"""The resource required for access (must be a valid Resource enum value)"""
|
||||
resource: String
|
||||
|
||||
"""The possession type required for access"""
|
||||
possession: AuthPossession
|
||||
) on FIELD_DEFINITION
|
||||
|
||||
type ParityCheck {
|
||||
@@ -615,7 +612,9 @@ enum ConfigErrorState {
|
||||
|
||||
type Permission {
|
||||
resource: Resource!
|
||||
actions: [String!]!
|
||||
|
||||
"""Actions allowed on this resource"""
|
||||
actions: [AuthAction!]!
|
||||
}
|
||||
|
||||
"""Available resources for permissions"""
|
||||
@@ -651,8 +650,36 @@ enum Resource {
|
||||
WELCOME
|
||||
}
|
||||
|
||||
"""Authentication actions with possession (e.g., create:any, read:own)"""
|
||||
enum AuthAction {
|
||||
"""Create any resource"""
|
||||
CREATE_ANY
|
||||
|
||||
"""Create own resource"""
|
||||
CREATE_OWN
|
||||
|
||||
"""Read any resource"""
|
||||
READ_ANY
|
||||
|
||||
"""Read own resource"""
|
||||
READ_OWN
|
||||
|
||||
"""Update any resource"""
|
||||
UPDATE_ANY
|
||||
|
||||
"""Update own resource"""
|
||||
UPDATE_OWN
|
||||
|
||||
"""Delete any resource"""
|
||||
DELETE_ANY
|
||||
|
||||
"""Delete own resource"""
|
||||
DELETE_OWN
|
||||
}
|
||||
|
||||
type ApiKey implements Node {
|
||||
id: PrefixedID!
|
||||
key: String!
|
||||
name: String!
|
||||
description: String
|
||||
roles: [Role!]!
|
||||
@@ -662,20 +689,90 @@ type ApiKey implements Node {
|
||||
|
||||
"""Available roles for API keys and users"""
|
||||
enum Role {
|
||||
"""Full administrative access to all resources"""
|
||||
ADMIN
|
||||
USER
|
||||
|
||||
"""Internal Role for Unraid Connect"""
|
||||
CONNECT
|
||||
|
||||
"""Basic read access to user profile only"""
|
||||
GUEST
|
||||
|
||||
"""Read-only access to all resources"""
|
||||
VIEWER
|
||||
}
|
||||
|
||||
type ApiKeyWithSecret implements Node {
|
||||
type SsoSettings implements Node {
|
||||
id: PrefixedID!
|
||||
name: String!
|
||||
description: String
|
||||
roles: [Role!]!
|
||||
createdAt: String!
|
||||
permissions: [Permission!]!
|
||||
key: String!
|
||||
|
||||
"""List of configured OIDC providers"""
|
||||
oidcProviders: [OidcProvider!]!
|
||||
}
|
||||
|
||||
type UnifiedSettings implements Node & FormSchema {
|
||||
id: PrefixedID!
|
||||
|
||||
"""The data schema for the settings"""
|
||||
dataSchema: JSON!
|
||||
|
||||
"""The UI schema for the settings"""
|
||||
uiSchema: JSON!
|
||||
|
||||
"""The current values of the settings"""
|
||||
values: JSON!
|
||||
}
|
||||
|
||||
interface FormSchema {
|
||||
"""The data schema for the form"""
|
||||
dataSchema: JSON!
|
||||
|
||||
"""The UI schema for the form"""
|
||||
uiSchema: JSON!
|
||||
|
||||
"""The current values of the form"""
|
||||
values: JSON!
|
||||
}
|
||||
|
||||
"""
|
||||
The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
|
||||
"""
|
||||
scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf")
|
||||
|
||||
type ApiKeyFormSettings implements Node & FormSchema {
|
||||
id: PrefixedID!
|
||||
|
||||
"""The data schema for the API key form"""
|
||||
dataSchema: JSON!
|
||||
|
||||
"""The UI schema for the API key form"""
|
||||
uiSchema: JSON!
|
||||
|
||||
"""The current values of the API key form"""
|
||||
values: JSON!
|
||||
}
|
||||
|
||||
type UpdateSettingsResponse {
|
||||
"""Whether a restart is required for the changes to take effect"""
|
||||
restartRequired: Boolean!
|
||||
|
||||
"""The updated settings values"""
|
||||
values: JSON!
|
||||
|
||||
"""Warning messages about configuration issues found during validation"""
|
||||
warnings: [String!]
|
||||
}
|
||||
|
||||
type Settings implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""A view of all settings"""
|
||||
unified: UnifiedSettings!
|
||||
|
||||
"""SSO settings"""
|
||||
sso: SsoSettings!
|
||||
|
||||
"""The API setting values"""
|
||||
api: ApiConfig!
|
||||
}
|
||||
|
||||
type RCloneDrive {
|
||||
@@ -686,11 +783,6 @@ type RCloneDrive {
|
||||
options: JSON!
|
||||
}
|
||||
|
||||
"""
|
||||
The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
|
||||
"""
|
||||
scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf")
|
||||
|
||||
type RCloneBackupConfigForm {
|
||||
id: ID!
|
||||
dataSchema: JSON!
|
||||
@@ -792,7 +884,7 @@ type VmMutations {
|
||||
"""API Key related mutations"""
|
||||
type ApiKeyMutations {
|
||||
"""Create an API key"""
|
||||
create(input: CreateApiKeyInput!): ApiKeyWithSecret!
|
||||
create(input: CreateApiKeyInput!): ApiKey!
|
||||
|
||||
"""Add a role to an API key"""
|
||||
addRole(input: AddRoleForApiKeyInput!): Boolean!
|
||||
@@ -804,7 +896,7 @@ type ApiKeyMutations {
|
||||
delete(input: DeleteApiKeyInput!): Boolean!
|
||||
|
||||
"""Update an API key"""
|
||||
update(input: UpdateApiKeyInput!): ApiKeyWithSecret!
|
||||
update(input: UpdateApiKeyInput!): ApiKey!
|
||||
}
|
||||
|
||||
input CreateApiKeyInput {
|
||||
@@ -821,7 +913,7 @@ input CreateApiKeyInput {
|
||||
|
||||
input AddPermissionInput {
|
||||
resource: Resource!
|
||||
actions: [String!]!
|
||||
actions: [AuthAction!]!
|
||||
}
|
||||
|
||||
input AddRoleForApiKeyInput {
|
||||
@@ -1727,50 +1819,6 @@ type ApiConfig {
|
||||
plugins: [String!]!
|
||||
}
|
||||
|
||||
type SsoSettings implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""List of configured OIDC providers"""
|
||||
oidcProviders: [OidcProvider!]!
|
||||
}
|
||||
|
||||
type UnifiedSettings implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""The data schema for the settings"""
|
||||
dataSchema: JSON!
|
||||
|
||||
"""The UI schema for the settings"""
|
||||
uiSchema: JSON!
|
||||
|
||||
"""The current values of the settings"""
|
||||
values: JSON!
|
||||
}
|
||||
|
||||
type UpdateSettingsResponse {
|
||||
"""Whether a restart is required for the changes to take effect"""
|
||||
restartRequired: Boolean!
|
||||
|
||||
"""The updated settings values"""
|
||||
values: JSON!
|
||||
|
||||
"""Warning messages about configuration issues found during validation"""
|
||||
warnings: [String!]
|
||||
}
|
||||
|
||||
type Settings implements Node {
|
||||
id: PrefixedID!
|
||||
|
||||
"""A view of all settings"""
|
||||
unified: UnifiedSettings!
|
||||
|
||||
"""SSO settings"""
|
||||
sso: SsoSettings!
|
||||
|
||||
"""The API setting values"""
|
||||
api: ApiConfig!
|
||||
}
|
||||
|
||||
type OidcAuthorizationRule {
|
||||
"""The claim to check (e.g., email, sub, groups, hd)"""
|
||||
claim: String!
|
||||
@@ -2243,6 +2291,20 @@ type Query {
|
||||
|
||||
"""All possible permissions for API keys"""
|
||||
apiKeyPossiblePermissions: [Permission!]!
|
||||
|
||||
"""Get the actual permissions that would be granted by a set of roles"""
|
||||
getPermissionsForRoles(roles: [Role!]!): [Permission!]!
|
||||
|
||||
"""
|
||||
Preview the effective permissions for a combination of roles and explicit permissions
|
||||
"""
|
||||
previewEffectivePermissions(roles: [Role!], permissions: [AddPermissionInput!]): [Permission!]!
|
||||
|
||||
"""Get all available authentication actions with possession"""
|
||||
getAvailableAuthActions: [AuthAction!]!
|
||||
|
||||
"""Get JSON Schema for API key creation form"""
|
||||
getApiKeyCreationFormSchema: ApiKeyFormSettings!
|
||||
config: Config!
|
||||
flash: Flash!
|
||||
logFiles: [LogFile!]!
|
||||
@@ -2538,19 +2600,4 @@ type Subscription {
|
||||
systemMetricsCpu: CpuUtilization!
|
||||
systemMetricsMemory: MemoryUtilization!
|
||||
upsUpdates: UPSDevice!
|
||||
}
|
||||
|
||||
"""Available authentication action verbs"""
|
||||
enum AuthActionVerb {
|
||||
CREATE
|
||||
UPDATE
|
||||
DELETE
|
||||
READ
|
||||
}
|
||||
|
||||
"""Available authentication possession types"""
|
||||
enum AuthPossession {
|
||||
ANY
|
||||
OWN
|
||||
OWN_ANY
|
||||
}
|
||||
@@ -2,15 +2,14 @@ import { Logger } from '@nestjs/common';
|
||||
import { readdir, readFile, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { ensureDir, ensureDirSync } from 'fs-extra';
|
||||
import { AuthActionVerb } from 'nest-authz';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { environment } from '@app/environment.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { ApiKey, ApiKeyWithSecret } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { ApiKey } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
|
||||
// Mock the store and its modules
|
||||
vi.mock('@app/store/index.js', () => ({
|
||||
@@ -48,28 +47,14 @@ describe('ApiKeyService', () => {
|
||||
|
||||
const mockApiKey: ApiKey = {
|
||||
id: 'test-api-id',
|
||||
key: 'test-secret-key',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ],
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const mockApiKeyWithSecret: ApiKeyWithSecret = {
|
||||
id: 'test-api-id',
|
||||
key: 'test-api-key',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -130,21 +115,23 @@ describe('ApiKeyService', () => {
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create ApiKeyWithSecret with generated key', async () => {
|
||||
it('should create ApiKey with generated key', async () => {
|
||||
const saveSpy = vi.spyOn(apiKeyService, 'saveApiKey').mockResolvedValue();
|
||||
const { key, id, description, roles } = mockApiKeyWithSecret;
|
||||
const { id, description, roles } = mockApiKey;
|
||||
const name = 'Test API Key';
|
||||
|
||||
const result = await apiKeyService.create({ name, description: description ?? '', roles });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id,
|
||||
key,
|
||||
name: name,
|
||||
description,
|
||||
roles,
|
||||
createdAt: expect.any(String),
|
||||
});
|
||||
expect(result.key).toBeDefined();
|
||||
expect(typeof result.key).toBe('string');
|
||||
expect(result.key.length).toBeGreaterThan(0);
|
||||
|
||||
expect(saveSpy).toHaveBeenCalledWith(result);
|
||||
});
|
||||
@@ -177,8 +164,8 @@ describe('ApiKeyService', () => {
|
||||
describe('findAll', () => {
|
||||
it('should return all API keys', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([
|
||||
mockApiKeyWithSecret,
|
||||
{ ...mockApiKeyWithSecret, id: 'second-id' },
|
||||
mockApiKey,
|
||||
{ ...mockApiKey, id: 'second-id' },
|
||||
]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
@@ -191,7 +178,7 @@ describe('ApiKeyService', () => {
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -202,7 +189,7 @@ describe('ApiKeyService', () => {
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -219,17 +206,17 @@ describe('ApiKeyService', () => {
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return API key by id when found', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKeyWithSecret]);
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKey]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
const result = await apiKeyService.findById(mockApiKeyWithSecret.id);
|
||||
const result = await apiKeyService.findById(mockApiKey.id);
|
||||
|
||||
expect(result).toMatchObject({ ...mockApiKey, createdAt: expect.any(String) });
|
||||
});
|
||||
|
||||
it('should return null if API key not found', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([
|
||||
{ ...mockApiKeyWithSecret, id: 'different-id' },
|
||||
{ ...mockApiKey, id: 'different-id' },
|
||||
]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
@@ -241,12 +228,12 @@ describe('ApiKeyService', () => {
|
||||
|
||||
describe('findByIdWithSecret', () => {
|
||||
it('should return API key with secret when found', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKeyWithSecret]);
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([mockApiKey]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
const result = await apiKeyService.findByIdWithSecret(mockApiKeyWithSecret.id);
|
||||
const result = await apiKeyService.findByIdWithSecret(mockApiKey.id);
|
||||
|
||||
expect(result).toEqual(mockApiKeyWithSecret);
|
||||
expect(result).toEqual(mockApiKey);
|
||||
});
|
||||
|
||||
it('should return null when API key not found', async () => {
|
||||
@@ -274,23 +261,20 @@ describe('ApiKeyService', () => {
|
||||
|
||||
describe('findByKey', () => {
|
||||
it('should return API key by key value when multiple keys exist', async () => {
|
||||
const differentKey = { ...mockApiKeyWithSecret, key: 'different-key' };
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([
|
||||
differentKey,
|
||||
mockApiKeyWithSecret,
|
||||
]);
|
||||
const differentKey = { ...mockApiKey, key: 'different-key' };
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([differentKey, mockApiKey]);
|
||||
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
const result = await apiKeyService.findByKey(mockApiKeyWithSecret.key);
|
||||
const result = await apiKeyService.findByKey(mockApiKey.key);
|
||||
|
||||
expect(result).toEqual(mockApiKeyWithSecret);
|
||||
expect(result).toEqual(mockApiKey);
|
||||
});
|
||||
|
||||
it('should return null if key not found in any file', async () => {
|
||||
vi.spyOn(apiKeyService, 'loadAllFromDisk').mockResolvedValue([
|
||||
{ ...mockApiKeyWithSecret, key: 'different-key-1' },
|
||||
{ ...mockApiKeyWithSecret, key: 'different-key-2' },
|
||||
{ ...mockApiKey, key: 'different-key-1' },
|
||||
{ ...mockApiKey, key: 'different-key-2' },
|
||||
]);
|
||||
await apiKeyService.onModuleInit();
|
||||
|
||||
@@ -314,21 +298,21 @@ describe('ApiKeyService', () => {
|
||||
it('should save API key to file', async () => {
|
||||
vi.mocked(writeFile).mockResolvedValue(undefined);
|
||||
|
||||
await apiKeyService.saveApiKey(mockApiKeyWithSecret);
|
||||
await apiKeyService.saveApiKey(mockApiKey);
|
||||
|
||||
const writeFileCalls = vi.mocked(writeFile).mock.calls;
|
||||
|
||||
expect(writeFileCalls.length).toBe(1);
|
||||
|
||||
const [filePath, fileContent] = writeFileCalls[0] ?? [];
|
||||
const expectedPath = join(mockBasePath, `${mockApiKeyWithSecret.id}.json`);
|
||||
const expectedPath = join(mockBasePath, `${mockApiKey.id}.json`);
|
||||
|
||||
expect(filePath).toBe(expectedPath);
|
||||
|
||||
if (typeof fileContent === 'string') {
|
||||
const savedApiKey = JSON.parse(fileContent);
|
||||
|
||||
expect(savedApiKey).toEqual(mockApiKeyWithSecret);
|
||||
expect(savedApiKey).toEqual(mockApiKey);
|
||||
} else {
|
||||
throw new Error('File content should be a string');
|
||||
}
|
||||
@@ -337,16 +321,16 @@ describe('ApiKeyService', () => {
|
||||
it('should throw GraphQLError on write error', async () => {
|
||||
vi.mocked(writeFile).mockRejectedValue(new Error('Write failed'));
|
||||
|
||||
await expect(apiKeyService.saveApiKey(mockApiKeyWithSecret)).rejects.toThrow(
|
||||
await expect(apiKeyService.saveApiKey(mockApiKey)).rejects.toThrow(
|
||||
'Failed to save API key: Write failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw GraphQLError on invalid API key structure', async () => {
|
||||
const invalidApiKey = {
|
||||
...mockApiKeyWithSecret,
|
||||
...mockApiKey,
|
||||
name: '', // Invalid: name cannot be empty
|
||||
} as ApiKeyWithSecret;
|
||||
} as ApiKey;
|
||||
|
||||
await expect(apiKeyService.saveApiKey(invalidApiKey)).rejects.toThrow(
|
||||
'Failed to save API key: Invalid data structure'
|
||||
@@ -355,10 +339,10 @@ describe('ApiKeyService', () => {
|
||||
|
||||
it('should throw GraphQLError when roles and permissions array is empty', async () => {
|
||||
const invalidApiKey = {
|
||||
...mockApiKeyWithSecret,
|
||||
...mockApiKey,
|
||||
permissions: [],
|
||||
roles: [],
|
||||
} as ApiKeyWithSecret;
|
||||
} as ApiKey;
|
||||
|
||||
await expect(apiKeyService.saveApiKey(invalidApiKey)).rejects.toThrow(
|
||||
'At least one of permissions or roles must be specified'
|
||||
@@ -367,7 +351,7 @@ describe('ApiKeyService', () => {
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
let updateMockApiKey: ApiKeyWithSecret;
|
||||
let updateMockApiKey: ApiKey;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh copy of the mock data for update tests
|
||||
@@ -380,7 +364,7 @@ describe('ApiKeyService', () => {
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -427,7 +411,7 @@ describe('ApiKeyService', () => {
|
||||
const updatedPermissions = [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ, AuthActionVerb.UPDATE],
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -474,7 +458,7 @@ describe('ApiKeyService', () => {
|
||||
});
|
||||
|
||||
describe('loadAllFromDisk', () => {
|
||||
let loadMockApiKey: ApiKeyWithSecret;
|
||||
let loadMockApiKey: ApiKey;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a fresh copy of the mock data for loadAllFromDisk tests
|
||||
@@ -487,7 +471,7 @@ describe('ApiKeyService', () => {
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -550,15 +534,62 @@ describe('ApiKeyService', () => {
|
||||
key: 'unique-key',
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize permission actions to lowercase when loading from disk', async () => {
|
||||
const apiKeyWithMixedCaseActions = {
|
||||
...loadMockApiKey,
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: ['READ:ANY', 'Update:Any', 'create:any', 'DELETE:ANY'], // Mixed case actions
|
||||
},
|
||||
{
|
||||
resource: Resource.ARRAY,
|
||||
actions: ['Read:Any'], // Mixed case
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(readdir).mockResolvedValue(['key1.json'] as any);
|
||||
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify(apiKeyWithMixedCaseActions));
|
||||
|
||||
const result = await apiKeyService.loadAllFromDisk();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
// All actions should be normalized to lowercase
|
||||
expect(result[0].permissions[0].actions).toEqual([
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.CREATE_ANY,
|
||||
AuthAction.DELETE_ANY,
|
||||
]);
|
||||
expect(result[0].permissions[1].actions).toEqual([AuthAction.READ_ANY]);
|
||||
});
|
||||
|
||||
it('should normalize roles to uppercase when loading from disk', async () => {
|
||||
const apiKeyWithMixedCaseRoles = {
|
||||
...loadMockApiKey,
|
||||
roles: ['admin', 'Viewer', 'CONNECT'], // Mixed case roles
|
||||
};
|
||||
|
||||
vi.mocked(readdir).mockResolvedValue(['key1.json'] as any);
|
||||
vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify(apiKeyWithMixedCaseRoles));
|
||||
|
||||
const result = await apiKeyService.loadAllFromDisk();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
// All roles should be normalized to uppercase
|
||||
expect(result[0].roles).toEqual(['ADMIN', 'VIEWER', 'CONNECT']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadApiKeyFile', () => {
|
||||
it('should load and parse a valid API key file', async () => {
|
||||
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockApiKeyWithSecret));
|
||||
vi.mocked(readFile).mockResolvedValue(JSON.stringify(mockApiKey));
|
||||
|
||||
const result = await apiKeyService['loadApiKeyFile']('test.json');
|
||||
|
||||
expect(result).toEqual(mockApiKeyWithSecret);
|
||||
expect(result).toEqual(mockApiKey);
|
||||
expect(readFile).toHaveBeenCalledWith(join(mockBasePath, 'test.json'), 'utf8');
|
||||
});
|
||||
|
||||
@@ -592,7 +623,7 @@ describe('ApiKeyService', () => {
|
||||
expect.stringContaining('Error validating API key file test.json')
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('An instance of ApiKeyWithSecret has failed the validation')
|
||||
expect.stringContaining('An instance of ApiKey has failed the validation')
|
||||
);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('property key'));
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('property id'));
|
||||
@@ -603,5 +634,50 @@ describe('ApiKeyService', () => {
|
||||
expect.stringContaining('property permissions')
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize legacy action formats when loading API keys', async () => {
|
||||
const legacyApiKey = {
|
||||
...mockApiKey,
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: ['create', 'READ', 'Update', 'DELETE'], // Mixed case legacy verbs
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: ['READ_ANY', 'UPDATE_OWN'], // GraphQL enum style
|
||||
},
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: ['read:own', 'update:any'], // Casbin colon format
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(readFile).mockResolvedValue(JSON.stringify(legacyApiKey));
|
||||
|
||||
const result = await apiKeyService['loadApiKeyFile']('legacy.json');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.permissions).toEqual([
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [
|
||||
AuthAction.CREATE_ANY,
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.DELETE_ANY,
|
||||
],
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_OWN],
|
||||
},
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthAction.READ_OWN, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,12 @@ import crypto from 'crypto';
|
||||
import { readdir, readFile, unlink, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { normalizeLegacyActions } from '@unraid/shared/util/permissions.js';
|
||||
import { watch } from 'chokidar';
|
||||
import { ValidationError } from 'class-validator';
|
||||
import { ensureDirSync } from 'fs-extra';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { AuthActionVerb } from 'nest-authz';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { environment } from '@app/environment.js';
|
||||
@@ -16,7 +16,6 @@ import { getters } from '@app/store/index.js';
|
||||
import {
|
||||
AddPermissionInput,
|
||||
ApiKey,
|
||||
ApiKeyWithSecret,
|
||||
Permission,
|
||||
} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js';
|
||||
@@ -26,7 +25,7 @@ import { batchProcess } from '@app/utils.js';
|
||||
export class ApiKeyService implements OnModuleInit {
|
||||
private readonly logger = new Logger(ApiKeyService.name);
|
||||
protected readonly basePath: string;
|
||||
protected memoryApiKeys: Array<ApiKeyWithSecret> = [];
|
||||
protected memoryApiKeys: Array<ApiKey> = [];
|
||||
private static readonly validRoles: Set<Role> = new Set(Object.values(Role));
|
||||
|
||||
constructor() {
|
||||
@@ -41,18 +40,8 @@ export class ApiKeyService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
public convertApiKeyWithSecretToApiKey(key: ApiKeyWithSecret): ApiKey {
|
||||
const { key: _, ...rest } = key;
|
||||
return rest;
|
||||
}
|
||||
|
||||
public async findAll(): Promise<ApiKey[]> {
|
||||
return Promise.all(
|
||||
this.memoryApiKeys.map(async (key) => {
|
||||
const keyWithoutSecret = this.convertApiKeyWithSecretToApiKey(key);
|
||||
return keyWithoutSecret;
|
||||
})
|
||||
);
|
||||
return this.memoryApiKeys;
|
||||
}
|
||||
|
||||
private setupWatch() {
|
||||
@@ -76,17 +65,18 @@ export class ApiKeyService implements OnModuleInit {
|
||||
public getAllValidPermissions(): Permission[] {
|
||||
return Object.values(Resource).map((res) => ({
|
||||
resource: res,
|
||||
actions: Object.values(AuthActionVerb),
|
||||
actions: Object.values(AuthAction),
|
||||
}));
|
||||
}
|
||||
|
||||
public convertPermissionsStringArrayToPermissions(permissions: string[]): Permission[] {
|
||||
return permissions.reduce<Array<Permission>>((acc, permission) => {
|
||||
const [resource, action] = permission.split(':');
|
||||
const [resource, ...actionParts] = permission.split(':');
|
||||
const action = actionParts.join(':'); // Handle actions like "read:any"
|
||||
const validatedResource = Resource[resource.toUpperCase() as keyof typeof Resource] ?? null;
|
||||
// Pull the actual enum value from the graphql schema
|
||||
const validatedAction =
|
||||
AuthActionVerb[action.toUpperCase() as keyof typeof AuthActionVerb] ?? null;
|
||||
AuthAction[action.toUpperCase().replace(':', '_') as keyof typeof AuthAction] ?? null;
|
||||
if (validatedAction && validatedResource) {
|
||||
const existingEntry = acc.find((p) => p.resource === validatedResource);
|
||||
if (existingEntry) {
|
||||
@@ -119,7 +109,7 @@ export class ApiKeyService implements OnModuleInit {
|
||||
roles?: Role[];
|
||||
permissions?: Permission[] | AddPermissionInput[];
|
||||
overwrite?: boolean;
|
||||
}): Promise<ApiKeyWithSecret> {
|
||||
}): Promise<ApiKey> {
|
||||
const trimmedName = name?.trim();
|
||||
const sanitizedName = this.sanitizeName(trimmedName);
|
||||
|
||||
@@ -139,7 +129,7 @@ export class ApiKeyService implements OnModuleInit {
|
||||
if (!overwrite && existingKey) {
|
||||
return existingKey;
|
||||
}
|
||||
const apiKey: Partial<ApiKeyWithSecret> = {
|
||||
const apiKey: Partial<ApiKey> = {
|
||||
id: uuidv4(),
|
||||
key: this.generateApiKey(),
|
||||
name: sanitizedName,
|
||||
@@ -152,18 +142,18 @@ export class ApiKeyService implements OnModuleInit {
|
||||
// Update createdAt date
|
||||
apiKey.createdAt = new Date().toISOString();
|
||||
|
||||
await this.saveApiKey(apiKey as ApiKeyWithSecret);
|
||||
await this.saveApiKey(apiKey as ApiKey);
|
||||
|
||||
return apiKey as ApiKeyWithSecret;
|
||||
return apiKey as ApiKey;
|
||||
}
|
||||
|
||||
async loadAllFromDisk(): Promise<ApiKeyWithSecret[]> {
|
||||
async loadAllFromDisk(): Promise<ApiKey[]> {
|
||||
const files = await readdir(this.basePath).catch((error) => {
|
||||
this.logger.error(`Failed to read API key directory: ${error}`);
|
||||
throw new Error('Failed to list API keys');
|
||||
});
|
||||
|
||||
const apiKeys: ApiKeyWithSecret[] = [];
|
||||
const apiKeys: ApiKey[] = [];
|
||||
const jsonFiles = files.filter((file) => file.includes('.json'));
|
||||
|
||||
for (const file of jsonFiles) {
|
||||
@@ -186,7 +176,7 @@ export class ApiKeyService implements OnModuleInit {
|
||||
* @param file The file to load
|
||||
* @returns The API key with secret
|
||||
*/
|
||||
private async loadApiKeyFile(file: string): Promise<ApiKeyWithSecret | null> {
|
||||
private async loadApiKeyFile(file: string): Promise<ApiKey | null> {
|
||||
try {
|
||||
const content = await readFile(join(this.basePath, file), 'utf8');
|
||||
|
||||
@@ -196,7 +186,17 @@ export class ApiKeyService implements OnModuleInit {
|
||||
if (parsedContent.roles) {
|
||||
parsedContent.roles = parsedContent.roles.map((role: string) => role.toUpperCase());
|
||||
}
|
||||
return await validateObject(ApiKeyWithSecret, parsedContent);
|
||||
|
||||
// Normalize permission actions to AuthAction enum values
|
||||
// Uses shared helper to handle all legacy formats
|
||||
if (parsedContent.permissions) {
|
||||
parsedContent.permissions = parsedContent.permissions.map((permission: any) => ({
|
||||
...permission,
|
||||
actions: normalizeLegacyActions(permission.actions || []),
|
||||
}));
|
||||
}
|
||||
|
||||
return await validateObject(ApiKey, parsedContent);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
this.logger.error(`Corrupted key file: ${file}`);
|
||||
@@ -216,12 +216,7 @@ export class ApiKeyService implements OnModuleInit {
|
||||
|
||||
async findById(id: string): Promise<ApiKey | null> {
|
||||
try {
|
||||
const key = this.findByField('id', id);
|
||||
|
||||
if (key) {
|
||||
return this.convertApiKeyWithSecretToApiKey(key);
|
||||
}
|
||||
return null;
|
||||
return this.findByField('id', id);
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
this.logApiKeyValidationError(id, error);
|
||||
@@ -231,17 +226,17 @@ export class ApiKeyService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
public findByIdWithSecret(id: string): ApiKeyWithSecret | null {
|
||||
public findByIdWithSecret(id: string): ApiKey | null {
|
||||
return this.findByField('id', id);
|
||||
}
|
||||
|
||||
public findByField(field: keyof ApiKeyWithSecret, value: string): ApiKeyWithSecret | null {
|
||||
public findByField(field: keyof ApiKey, value: string): ApiKey | null {
|
||||
if (!value) return null;
|
||||
|
||||
return this.memoryApiKeys.find((k) => k[field] === value) ?? null;
|
||||
}
|
||||
|
||||
findByKey(key: string): ApiKeyWithSecret | null {
|
||||
findByKey(key: string): ApiKey | null {
|
||||
return this.findByField('key', key);
|
||||
}
|
||||
|
||||
@@ -254,9 +249,9 @@ export class ApiKeyService implements OnModuleInit {
|
||||
Errors: ${JSON.stringify(error.constraints, null, 2)}`);
|
||||
}
|
||||
|
||||
public async saveApiKey(apiKey: ApiKeyWithSecret): Promise<void> {
|
||||
public async saveApiKey(apiKey: ApiKey): Promise<void> {
|
||||
try {
|
||||
const validatedApiKey = await validateObject(ApiKeyWithSecret, apiKey);
|
||||
const validatedApiKey = await validateObject(ApiKey, apiKey);
|
||||
if (!validatedApiKey.permissions?.length && !validatedApiKey.roles?.length) {
|
||||
throw new GraphQLError('At least one of permissions or roles must be specified');
|
||||
}
|
||||
@@ -266,7 +261,7 @@ export class ApiKeyService implements OnModuleInit {
|
||||
.reduce((acc, key) => {
|
||||
acc[key] = validatedApiKey[key];
|
||||
return acc;
|
||||
}, {} as ApiKeyWithSecret);
|
||||
}, {} as ApiKey);
|
||||
|
||||
await writeFile(
|
||||
join(this.basePath, `${validatedApiKey.id}.json`),
|
||||
@@ -334,7 +329,7 @@ export class ApiKeyService implements OnModuleInit {
|
||||
description?: string;
|
||||
roles?: Role[];
|
||||
permissions?: Permission[] | AddPermissionInput[];
|
||||
}): Promise<ApiKeyWithSecret> {
|
||||
}): Promise<ApiKey> {
|
||||
const apiKey = this.findByIdWithSecret(id);
|
||||
if (!apiKey) {
|
||||
throw new GraphQLError('API key not found');
|
||||
@@ -345,13 +340,15 @@ export class ApiKeyService implements OnModuleInit {
|
||||
if (description !== undefined) {
|
||||
apiKey.description = description;
|
||||
}
|
||||
if (roles) {
|
||||
if (roles !== undefined) {
|
||||
// Handle both empty array (to clear roles) and populated array
|
||||
if (roles.some((role) => !ApiKeyService.validRoles.has(role))) {
|
||||
throw new GraphQLError('Invalid role specified');
|
||||
}
|
||||
apiKey.roles = roles;
|
||||
}
|
||||
if (permissions) {
|
||||
if (permissions !== undefined) {
|
||||
// Handle both empty array (to clear permissions) and populated array
|
||||
apiKey.permissions = permissions;
|
||||
}
|
||||
await this.saveApiKey(apiKey);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { newEnforcer } from 'casbin';
|
||||
import { AuthActionVerb, AuthZService } from 'nest-authz';
|
||||
import { AuthZService } from 'nest-authz';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
|
||||
import { ApiKey, ApiKeyWithSecret } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { ApiKey } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { UserAccount } from '@app/unraid-api/graph/user/user.model.js';
|
||||
import { FastifyRequest } from '@app/unraid-api/types/fastify.js';
|
||||
|
||||
@@ -19,15 +19,6 @@ describe('AuthService', () => {
|
||||
let cookieService: CookieService;
|
||||
|
||||
const mockApiKey: ApiKey = {
|
||||
id: '10f356da-1e9e-43b8-9028-a26a645539a6',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST, Role.CONNECT],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
const mockApiKeyWithSecret: ApiKeyWithSecret = {
|
||||
id: 'test-api-id',
|
||||
key: 'test-api-key',
|
||||
name: 'Test API Key',
|
||||
@@ -36,7 +27,7 @@ describe('AuthService', () => {
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.CONNECT,
|
||||
actions: [AuthActionVerb.READ.toUpperCase()],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -98,6 +89,43 @@ describe('AuthService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate API key with only permissions (no roles)', async () => {
|
||||
const apiKeyWithOnlyPermissions: ApiKey = {
|
||||
...mockApiKey,
|
||||
roles: [], // No roles, only permissions
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.spyOn(apiKeyService, 'findByKey').mockResolvedValue(apiKeyWithOnlyPermissions);
|
||||
vi.spyOn(authService, 'syncApiKeyRoles').mockResolvedValue(undefined);
|
||||
vi.spyOn(authService, 'syncApiKeyPermissions').mockResolvedValue(undefined);
|
||||
vi.spyOn(authzService, 'getRolesForUser').mockResolvedValue([]);
|
||||
|
||||
const result = await authService.validateApiKeyCasbin('test-api-key');
|
||||
|
||||
expect(result).toEqual({
|
||||
id: apiKeyWithOnlyPermissions.id,
|
||||
name: apiKeyWithOnlyPermissions.name,
|
||||
description: apiKeyWithOnlyPermissions.description,
|
||||
roles: [],
|
||||
permissions: apiKeyWithOnlyPermissions.permissions,
|
||||
});
|
||||
expect(authService.syncApiKeyRoles).toHaveBeenCalledWith(apiKeyWithOnlyPermissions.id, []);
|
||||
expect(authService.syncApiKeyPermissions).toHaveBeenCalledWith(
|
||||
apiKeyWithOnlyPermissions.id,
|
||||
apiKeyWithOnlyPermissions.permissions
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException when session user is missing', async () => {
|
||||
vi.spyOn(cookieService, 'hasValidAuthCookie').mockResolvedValue(true);
|
||||
vi.spyOn(authService, 'getSessionUser').mockResolvedValue(null as unknown as UserAccount);
|
||||
@@ -196,7 +224,7 @@ describe('AuthService', () => {
|
||||
|
||||
vi.spyOn(apiKeyService, 'findById').mockResolvedValue(mockApiKeyWithoutRole);
|
||||
vi.spyOn(apiKeyService, 'findByIdWithSecret').mockResolvedValue({
|
||||
...mockApiKeyWithSecret,
|
||||
...mockApiKey,
|
||||
roles: [Role.ADMIN],
|
||||
});
|
||||
vi.spyOn(apiKeyService, 'saveApiKey').mockResolvedValue();
|
||||
@@ -208,7 +236,7 @@ describe('AuthService', () => {
|
||||
expect(apiKeyService.findById).toHaveBeenCalledWith(apiKeyId);
|
||||
expect(apiKeyService.findByIdWithSecret).toHaveBeenCalledWith(apiKeyId);
|
||||
expect(apiKeyService.saveApiKey).toHaveBeenCalledWith({
|
||||
...mockApiKeyWithSecret,
|
||||
...mockApiKey,
|
||||
roles: [Role.ADMIN, role],
|
||||
});
|
||||
expect(authzService.addRoleForUser).toHaveBeenCalledWith(apiKeyId, role);
|
||||
@@ -227,7 +255,7 @@ describe('AuthService', () => {
|
||||
it('should remove role from API key', async () => {
|
||||
const apiKey = { ...mockApiKey, roles: [Role.ADMIN, Role.GUEST] };
|
||||
const apiKeyWithSecret = {
|
||||
...mockApiKeyWithSecret,
|
||||
...mockApiKey,
|
||||
roles: [Role.ADMIN, Role.GUEST],
|
||||
};
|
||||
|
||||
@@ -256,4 +284,229 @@ describe('AuthService', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VIEWER role API_KEY access restriction', () => {
|
||||
it('should deny VIEWER role access to API_KEY resource', async () => {
|
||||
// Test that VIEWER role cannot access API_KEY resource
|
||||
const mockCasbinPermissions = Object.values(Resource)
|
||||
.filter((resource) => resource !== Resource.API_KEY)
|
||||
.map((resource) => ['VIEWER', resource, AuthAction.READ_ANY]);
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.VIEWER);
|
||||
|
||||
// VIEWER should have read access to all resources EXCEPT API_KEY
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBeGreaterThan(0);
|
||||
|
||||
// Should NOT have API_KEY in the permissions
|
||||
expect(result.has(Resource.API_KEY)).toBe(false);
|
||||
|
||||
// Should have read access to other resources
|
||||
expect(result.get(Resource.DOCKER)).toEqual([AuthAction.READ_ANY]);
|
||||
expect(result.get(Resource.ARRAY)).toEqual([AuthAction.READ_ANY]);
|
||||
expect(result.get(Resource.CONFIG)).toEqual([AuthAction.READ_ANY]);
|
||||
expect(result.get(Resource.ME)).toEqual([AuthAction.READ_ANY]);
|
||||
});
|
||||
|
||||
it('should allow ADMIN role access to API_KEY resource', async () => {
|
||||
// Test that ADMIN role CAN access API_KEY resource
|
||||
const mockCasbinPermissions = [
|
||||
['ADMIN', '*', '*'], // Admin has wildcard access
|
||||
];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
// ADMIN should have access to API_KEY through wildcard
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.has(Resource.API_KEY)).toBe(true);
|
||||
expect(result.get(Resource.API_KEY)).toContain(AuthAction.CREATE_ANY);
|
||||
expect(result.get(Resource.API_KEY)).toContain(AuthAction.READ_ANY);
|
||||
expect(result.get(Resource.API_KEY)).toContain(AuthAction.UPDATE_ANY);
|
||||
expect(result.get(Resource.API_KEY)).toContain(AuthAction.DELETE_ANY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getImplicitPermissionsForRole', () => {
|
||||
it('should return permissions for a role', async () => {
|
||||
const mockCasbinPermissions = [
|
||||
['ADMIN', 'DOCKER', 'READ'],
|
||||
['ADMIN', 'DOCKER', 'UPDATE'],
|
||||
['ADMIN', 'VMS', 'READ'],
|
||||
];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get(Resource.DOCKER)).toEqual([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]);
|
||||
expect(result.get(Resource.VMS)).toEqual([AuthAction.READ_ANY]);
|
||||
});
|
||||
|
||||
it('should handle wildcard permissions for admin role', async () => {
|
||||
const mockCasbinPermissions = [
|
||||
['ADMIN', '*', '*'],
|
||||
['ADMIN', 'ME', 'READ'], // Inherited from GUEST
|
||||
];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBeGreaterThan(0);
|
||||
// Should have expanded CRUD actions with proper format for all resources
|
||||
expect(result.get(Resource.DOCKER)).toContain(AuthAction.CREATE_ANY);
|
||||
expect(result.get(Resource.DOCKER)).toContain(AuthAction.READ_ANY);
|
||||
expect(result.get(Resource.DOCKER)).toContain(AuthAction.UPDATE_ANY);
|
||||
expect(result.get(Resource.DOCKER)).toContain(AuthAction.DELETE_ANY);
|
||||
expect(result.get(Resource.VMS)).toContain(AuthAction.CREATE_ANY);
|
||||
expect(result.get(Resource.VMS)).toContain(AuthAction.READ_ANY);
|
||||
expect(result.get(Resource.VMS)).toContain(AuthAction.UPDATE_ANY);
|
||||
expect(result.get(Resource.VMS)).toContain(AuthAction.DELETE_ANY);
|
||||
expect(result.get(Resource.ME)).toContain(AuthAction.READ_ANY);
|
||||
expect(result.get(Resource.ME)).toContain(AuthAction.CREATE_ANY); // Also gets CRUD from wildcard
|
||||
expect(result.has('*' as any)).toBe(false); // Still shouldn't have literal wildcard
|
||||
});
|
||||
|
||||
it('should handle connect role with wildcard resource and specific action', async () => {
|
||||
const mockCasbinPermissions = [
|
||||
['CONNECT', '*', 'READ'],
|
||||
['CONNECT', 'CONNECT__REMOTE_ACCESS', 'UPDATE'],
|
||||
['CONNECT', 'ME', 'READ'], // Inherited from GUEST
|
||||
];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.CONNECT);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBeGreaterThan(0);
|
||||
// All resources should have READ
|
||||
expect(result.get(Resource.DOCKER)).toContain(AuthAction.READ_ANY);
|
||||
expect(result.get(Resource.VMS)).toContain(AuthAction.READ_ANY);
|
||||
expect(result.get(Resource.ARRAY)).toContain(AuthAction.READ_ANY);
|
||||
// CONNECT__REMOTE_ACCESS should have both READ and UPDATE
|
||||
expect(result.get(Resource.CONNECT__REMOTE_ACCESS)).toContain(AuthAction.READ_ANY);
|
||||
expect(result.get(Resource.CONNECT__REMOTE_ACCESS)).toContain(AuthAction.UPDATE_ANY);
|
||||
});
|
||||
|
||||
it('should expand resource-specific wildcard actions to CRUD', async () => {
|
||||
const mockCasbinPermissions = [
|
||||
['DOCKER_MANAGER', 'DOCKER', '*'],
|
||||
['DOCKER_MANAGER', 'ARRAY', 'READ'],
|
||||
];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
// Docker should have all CRUD actions with proper format
|
||||
expect(result.get(Resource.DOCKER)).toEqual(
|
||||
expect.arrayContaining([
|
||||
AuthAction.CREATE_ANY,
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.DELETE_ANY,
|
||||
])
|
||||
);
|
||||
// Array should only have READ
|
||||
expect(result.get(Resource.ARRAY)).toEqual([AuthAction.READ_ANY]);
|
||||
});
|
||||
|
||||
it('should skip invalid resources', async () => {
|
||||
const mockCasbinPermissions = [
|
||||
['ADMIN', 'INVALID_RESOURCE', 'READ'],
|
||||
['ADMIN', 'DOCKER', 'UPDATE'],
|
||||
['ADMIN', '', 'READ'],
|
||||
] as string[][];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(Resource.DOCKER)).toEqual([AuthAction.UPDATE_ANY]);
|
||||
});
|
||||
|
||||
it('should handle empty permissions', async () => {
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue([]);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle malformed permission entries', async () => {
|
||||
const mockCasbinPermissions = [
|
||||
['ADMIN'], // Too short
|
||||
['ADMIN', 'DOCKER'], // Missing action
|
||||
['ADMIN', 'DOCKER', 'READ', 'EXTRA'], // Extra fields are ok
|
||||
['ADMIN', 'VMS', 'UPDATE'],
|
||||
];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get(Resource.DOCKER)).toEqual([AuthAction.READ_ANY]);
|
||||
expect(result.get(Resource.VMS)).toEqual([AuthAction.UPDATE_ANY]);
|
||||
});
|
||||
|
||||
it('should not duplicate actions for the same resource', async () => {
|
||||
const mockCasbinPermissions = [
|
||||
['ADMIN', 'DOCKER', 'READ'],
|
||||
['ADMIN', 'DOCKER', 'READ'],
|
||||
['ADMIN', 'DOCKER', 'UPDATE'],
|
||||
['ADMIN', 'DOCKER', 'UPDATE'],
|
||||
];
|
||||
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockResolvedValue(
|
||||
mockCasbinPermissions
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get(Resource.DOCKER)).toEqual([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
vi.spyOn(authzService, 'getImplicitPermissionsForUser').mockRejectedValue(
|
||||
new Error('Casbin error')
|
||||
);
|
||||
|
||||
const result = await authService.getImplicitPermissionsForRole(Role.ADMIN);
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
import { Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
convertPermissionSetsToArrays,
|
||||
expandWildcardAction,
|
||||
parseActionToAuthAction,
|
||||
reconcileWildcardPermissions,
|
||||
} from '@unraid/shared/util/permissions.js';
|
||||
import { AuthZService } from 'nest-authz';
|
||||
|
||||
import { getters } from '@app/store/index.js';
|
||||
@@ -111,12 +117,36 @@ export class AuthService {
|
||||
await this.authzService.deletePermissionsForUser(apiKeyId);
|
||||
|
||||
// Create array of permission-action pairs for processing
|
||||
const permissionActions = permissions.flatMap((permission) =>
|
||||
(permission.actions || []).map((action) => ({
|
||||
resource: permission.resource,
|
||||
action,
|
||||
}))
|
||||
);
|
||||
// Filter out any permissions with empty or undefined resources
|
||||
const permissionActions = permissions
|
||||
.filter((permission) => permission.resource && permission.resource.trim() !== '')
|
||||
.flatMap((permission) =>
|
||||
(permission.actions || [])
|
||||
.filter((action) => action && String(action).trim() !== '')
|
||||
.flatMap((action) => {
|
||||
const actionStr = String(action);
|
||||
// Handle wildcard - expand to all CRUD actions
|
||||
if (actionStr === '*' || actionStr.toLowerCase() === '*') {
|
||||
return expandWildcardAction().map((expandedAction) => ({
|
||||
resource: permission.resource,
|
||||
action: expandedAction,
|
||||
}));
|
||||
}
|
||||
|
||||
// Use the shared helper to parse and validate the action
|
||||
const parsedAction = parseActionToAuthAction(actionStr);
|
||||
|
||||
// Only include valid AuthAction values
|
||||
return parsedAction
|
||||
? [
|
||||
{
|
||||
resource: permission.resource,
|
||||
action: parsedAction,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
})
|
||||
);
|
||||
|
||||
const { errors, errorOccurred: errorOccured } = await batchProcess(
|
||||
permissionActions,
|
||||
@@ -227,6 +257,63 @@ export class AuthService {
|
||||
return Boolean(token) && token === getters.emhttp().var.csrfToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get implicit permissions for a role (including inherited permissions)
|
||||
*/
|
||||
public async getImplicitPermissionsForRole(role: Role): Promise<Map<Resource, AuthAction[]>> {
|
||||
// Use Set internally for efficient deduplication, with '*' as a special key for wildcards
|
||||
const permissionsWithSets = new Map<Resource | '*', Set<AuthAction>>();
|
||||
|
||||
// Load permissions from Casbin, defaulting to empty array on error
|
||||
let casbinPermissions: string[][] = [];
|
||||
try {
|
||||
casbinPermissions = await this.authzService.getImplicitPermissionsForUser(role);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get permissions for role ${role}:`, error);
|
||||
}
|
||||
|
||||
// Parse the Casbin permissions format: [["role", "resource", "action"], ...]
|
||||
for (const perm of casbinPermissions) {
|
||||
if (perm.length < 3) continue;
|
||||
|
||||
const resourceStr = perm[1];
|
||||
const action = perm[2];
|
||||
|
||||
if (!resourceStr) continue;
|
||||
|
||||
// Skip invalid resources (except wildcard)
|
||||
if (resourceStr !== '*' && !Object.values(Resource).includes(resourceStr as Resource)) {
|
||||
this.logger.debug(`Skipping invalid resource from Casbin: ${resourceStr}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Initialize Set if needed
|
||||
if (!permissionsWithSets.has(resourceStr as Resource | '*')) {
|
||||
permissionsWithSets.set(resourceStr as Resource | '*', new Set());
|
||||
}
|
||||
|
||||
const actionsSet = permissionsWithSets.get(resourceStr as Resource | '*')!;
|
||||
|
||||
// Handle wildcard or parse to valid AuthAction
|
||||
if (action === '*') {
|
||||
// Expand wildcard action to CRUD operations
|
||||
expandWildcardAction().forEach((a) => actionsSet.add(a));
|
||||
} else {
|
||||
// Use shared helper to parse and validate action
|
||||
const parsedAction = parseActionToAuthAction(action);
|
||||
if (parsedAction) {
|
||||
actionsSet.add(parsedAction);
|
||||
} else {
|
||||
this.logger.debug(`Skipping invalid action from Casbin: ${action}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile wildcard permissions and convert to final format
|
||||
reconcileWildcardPermissions(permissionsWithSets);
|
||||
return convertPermissionSetsToArrays(permissionsWithSets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a user object representing a session.
|
||||
* Note: Does NOT perform validation.
|
||||
|
||||
@@ -12,7 +12,7 @@ g = _, _
|
||||
e = some(where (p.eft == allow))
|
||||
|
||||
[matchers]
|
||||
m = (regexMatch(r.sub, p.sub) || g(r.sub, p.sub)) && \
|
||||
regexMatch(lower(r.obj), lower(p.obj)) && \
|
||||
(regexMatch(lower(r.act), lower(p.act)) || p.act == '*' || regexMatch(lower(r.act), lower(concat(p.act, ':.*'))))
|
||||
m = (r.sub == p.sub || g(r.sub, p.sub)) && \
|
||||
(r.obj == p.obj || p.obj == '*') && \
|
||||
(r.act == p.act || p.act == '*')
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { Model as CasbinModel, newEnforcer, StringAdapter } from 'casbin';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { CASBIN_MODEL } from '@app/unraid-api/auth/casbin/model.js';
|
||||
import { BASE_POLICY } from '@app/unraid-api/auth/casbin/policy.js';
|
||||
|
||||
describe('Comprehensive Casbin Permissions Tests', () => {
|
||||
describe('All UsePermissions decorator combinations', () => {
|
||||
// Test all resource/action combinations used in the codebase
|
||||
const testCases = [
|
||||
// API_KEY permissions
|
||||
{
|
||||
resource: Resource.API_KEY,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
|
||||
},
|
||||
{
|
||||
resource: Resource.API_KEY,
|
||||
action: AuthAction.CREATE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
|
||||
},
|
||||
{
|
||||
resource: Resource.API_KEY,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
|
||||
},
|
||||
{
|
||||
resource: Resource.API_KEY,
|
||||
action: AuthAction.DELETE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
|
||||
},
|
||||
|
||||
// PERMISSION resource (for listing possible permissions)
|
||||
{
|
||||
resource: Resource.PERMISSION,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
|
||||
// ARRAY permissions
|
||||
{
|
||||
resource: Resource.ARRAY,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.ARRAY,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST],
|
||||
},
|
||||
|
||||
// CONFIG permissions
|
||||
{
|
||||
resource: Resource.CONFIG,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.CONFIG,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
|
||||
},
|
||||
|
||||
// DOCKER permissions
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST],
|
||||
},
|
||||
|
||||
// VMS permissions
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST],
|
||||
},
|
||||
|
||||
// FLASH permissions (includes rclone operations)
|
||||
{
|
||||
resource: Resource.FLASH,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.FLASH,
|
||||
action: AuthAction.CREATE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
|
||||
},
|
||||
{
|
||||
resource: Resource.FLASH,
|
||||
action: AuthAction.DELETE_ANY,
|
||||
allowedRoles: [Role.ADMIN],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST, Role.CONNECT],
|
||||
},
|
||||
|
||||
// INFO permissions (system information)
|
||||
{
|
||||
resource: Resource.INFO,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
|
||||
// LOGS permissions
|
||||
{
|
||||
resource: Resource.LOGS,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
|
||||
// ME permissions (current user info)
|
||||
{
|
||||
resource: Resource.ME,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT, Role.GUEST],
|
||||
deniedRoles: [],
|
||||
},
|
||||
|
||||
// NOTIFICATIONS permissions
|
||||
{
|
||||
resource: Resource.NOTIFICATIONS,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
|
||||
// Other read-only resources for VIEWER
|
||||
{
|
||||
resource: Resource.DISK,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.DISPLAY,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.ONLINE,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.OWNER,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.REGISTRATION,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.SERVERS,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.SERVICES,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.SHARE,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.VARS,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.CUSTOMIZATIONS,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.ACTIVATION_CODE,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
|
||||
// CONNECT special permission for remote access
|
||||
{
|
||||
resource: Resource.CONNECT__REMOTE_ACCESS,
|
||||
action: AuthAction.READ_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.VIEWER, Role.CONNECT],
|
||||
deniedRoles: [Role.GUEST],
|
||||
},
|
||||
{
|
||||
resource: Resource.CONNECT__REMOTE_ACCESS,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
allowedRoles: [Role.ADMIN, Role.CONNECT],
|
||||
deniedRoles: [Role.VIEWER, Role.GUEST],
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ resource, action, allowedRoles, deniedRoles }) => {
|
||||
describe(`${resource} - ${action}`, () => {
|
||||
let enforcer: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
enforcer = await newEnforcer(model, adapter);
|
||||
});
|
||||
|
||||
allowedRoles.forEach((role) => {
|
||||
it(`should allow ${role} to ${action} ${resource}`, async () => {
|
||||
const result = await enforcer.enforce(role, resource, action);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
deniedRoles.forEach((role) => {
|
||||
it(`should deny ${role} to ${action} ${resource}`, async () => {
|
||||
const result = await enforcer.enforce(role, resource, action);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Action matching and normalization', () => {
|
||||
let enforcer: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
enforcer = await newEnforcer(model, adapter);
|
||||
});
|
||||
|
||||
it('should match actions exactly as stored (uppercase)', async () => {
|
||||
// Our policies store actions as uppercase (e.g., 'READ_ANY')
|
||||
// The matcher now requires exact matching for security
|
||||
|
||||
// Uppercase actions should work
|
||||
const adminUpperResult = await enforcer.enforce(
|
||||
Role.ADMIN,
|
||||
Resource.DOCKER,
|
||||
AuthAction.READ_ANY
|
||||
);
|
||||
expect(adminUpperResult).toBe(true);
|
||||
|
||||
const viewerUpperResult = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.DOCKER,
|
||||
AuthAction.READ_ANY
|
||||
);
|
||||
expect(viewerUpperResult).toBe(true);
|
||||
|
||||
// For non-wildcard roles, lowercase actions won't match
|
||||
const viewerLowerResult = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, 'read:any');
|
||||
expect(viewerLowerResult).toBe(false);
|
||||
|
||||
// Mixed case won't match for VIEWER either
|
||||
const viewerMixedResult = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, 'Read_Any');
|
||||
expect(viewerMixedResult).toBe(false);
|
||||
|
||||
// GUEST also requires exact lowercase
|
||||
const guestUpperResult = await enforcer.enforce(Role.GUEST, Resource.ME, 'READ:ANY');
|
||||
expect(guestUpperResult).toBe(false);
|
||||
|
||||
const guestLowerResult = await enforcer.enforce(
|
||||
Role.GUEST,
|
||||
Resource.ME,
|
||||
AuthAction.READ_ANY
|
||||
);
|
||||
expect(guestLowerResult).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow wildcard actions for ADMIN regardless of case', async () => {
|
||||
// ADMIN has wildcard permissions (*, *, *) which match any action
|
||||
const adminWildcardActions = [
|
||||
'read:any',
|
||||
'create:any',
|
||||
'update:any',
|
||||
'delete:any',
|
||||
'READ:ANY', // Even uppercase works due to wildcard
|
||||
'ANYTHING', // Any action works due to wildcard
|
||||
];
|
||||
|
||||
for (const action of adminWildcardActions) {
|
||||
const result = await enforcer.enforce(Role.ADMIN, Resource.DOCKER, action);
|
||||
expect(result).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT match different actions even with correct case', async () => {
|
||||
// VIEWER should not be able to UPDATE even with correct lowercase
|
||||
const result = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, AuthAction.UPDATE_ANY);
|
||||
expect(result).toBe(false);
|
||||
|
||||
// VIEWER should not be able to DELETE
|
||||
const deleteResult = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.DOCKER,
|
||||
AuthAction.DELETE_ANY
|
||||
);
|
||||
expect(deleteResult).toBe(false);
|
||||
|
||||
// VIEWER should not be able to CREATE
|
||||
const createResult = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.DOCKER,
|
||||
AuthAction.CREATE_ANY
|
||||
);
|
||||
expect(createResult).toBe(false);
|
||||
});
|
||||
|
||||
it('should ensure actions are normalized when stored', async () => {
|
||||
// This test documents that our auth service normalizes actions to uppercase
|
||||
// when syncing permissions, ensuring consistency
|
||||
|
||||
// The BASE_POLICY uses AuthAction.READ_ANY which is 'READ_ANY' (uppercase)
|
||||
expect(BASE_POLICY).toContain('READ_ANY');
|
||||
expect(BASE_POLICY).not.toContain('read:any');
|
||||
|
||||
// All our stored policies should be uppercase
|
||||
const policies = await enforcer.getPolicy();
|
||||
for (const policy of policies) {
|
||||
const action = policy[2]; // Third element is the action
|
||||
if (action && action !== '*') {
|
||||
// All non-wildcard actions should be uppercase
|
||||
expect(action).toBe(action.toUpperCase());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Wildcard permissions', () => {
|
||||
let enforcer: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
enforcer = await newEnforcer(model, adapter);
|
||||
});
|
||||
|
||||
it('should allow ADMIN wildcard access to all resources and actions', async () => {
|
||||
const resources = Object.values(Resource);
|
||||
const actions = [
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.CREATE_ANY,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.DELETE_ANY,
|
||||
];
|
||||
|
||||
for (const resource of resources) {
|
||||
for (const action of actions) {
|
||||
const result = await enforcer.enforce(Role.ADMIN, resource, action);
|
||||
expect(result).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should allow CONNECT read access to most resources but NOT API_KEY', async () => {
|
||||
const resources = Object.values(Resource).filter(
|
||||
(r) => r !== Resource.CONNECT__REMOTE_ACCESS && r !== Resource.API_KEY
|
||||
);
|
||||
|
||||
for (const resource of resources) {
|
||||
// Should be able to read most resources
|
||||
const readResult = await enforcer.enforce(Role.CONNECT, resource, AuthAction.READ_ANY);
|
||||
expect(readResult).toBe(true);
|
||||
|
||||
// Should NOT be able to write (except CONNECT__REMOTE_ACCESS)
|
||||
const updateResult = await enforcer.enforce(
|
||||
Role.CONNECT,
|
||||
resource,
|
||||
AuthAction.UPDATE_ANY
|
||||
);
|
||||
expect(updateResult).toBe(false);
|
||||
}
|
||||
|
||||
// CONNECT should NOT be able to read API_KEY
|
||||
const apiKeyRead = await enforcer.enforce(
|
||||
Role.CONNECT,
|
||||
Resource.API_KEY,
|
||||
AuthAction.READ_ANY
|
||||
);
|
||||
expect(apiKeyRead).toBe(false);
|
||||
|
||||
// CONNECT should NOT be able to perform any action on API_KEY
|
||||
const apiKeyCreate = await enforcer.enforce(
|
||||
Role.CONNECT,
|
||||
Resource.API_KEY,
|
||||
AuthAction.CREATE_ANY
|
||||
);
|
||||
expect(apiKeyCreate).toBe(false);
|
||||
const apiKeyUpdate = await enforcer.enforce(
|
||||
Role.CONNECT,
|
||||
Resource.API_KEY,
|
||||
AuthAction.UPDATE_ANY
|
||||
);
|
||||
expect(apiKeyUpdate).toBe(false);
|
||||
const apiKeyDelete = await enforcer.enforce(
|
||||
Role.CONNECT,
|
||||
Resource.API_KEY,
|
||||
AuthAction.DELETE_ANY
|
||||
);
|
||||
expect(apiKeyDelete).toBe(false);
|
||||
|
||||
// Special case: CONNECT can update CONNECT__REMOTE_ACCESS
|
||||
const remoteAccessUpdate = await enforcer.enforce(
|
||||
Role.CONNECT,
|
||||
Resource.CONNECT__REMOTE_ACCESS,
|
||||
AuthAction.UPDATE_ANY
|
||||
);
|
||||
expect(remoteAccessUpdate).toBe(true);
|
||||
});
|
||||
|
||||
it('should explicitly deny CONNECT role from accessing API_KEY to prevent secret exposure', async () => {
|
||||
// CONNECT should NOT be able to read API_KEY (which would expose secrets)
|
||||
const apiKeyRead = await enforcer.enforce(
|
||||
Role.CONNECT,
|
||||
Resource.API_KEY,
|
||||
AuthAction.READ_ANY
|
||||
);
|
||||
expect(apiKeyRead).toBe(false);
|
||||
|
||||
// Verify all API_KEY operations are denied for CONNECT
|
||||
const actions = ['create:any', 'read:any', 'update:any', 'delete:any'];
|
||||
for (const action of actions) {
|
||||
const result = await enforcer.enforce(Role.CONNECT, Resource.API_KEY, action);
|
||||
expect(result).toBe(false);
|
||||
}
|
||||
|
||||
// Verify ADMIN can still access API_KEY
|
||||
const adminApiKeyRead = await enforcer.enforce(
|
||||
Role.ADMIN,
|
||||
Resource.API_KEY,
|
||||
AuthAction.READ_ANY
|
||||
);
|
||||
expect(adminApiKeyRead).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role inheritance', () => {
|
||||
let enforcer: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
enforcer = await newEnforcer(model, adapter);
|
||||
});
|
||||
|
||||
it('should inherit GUEST permissions for VIEWER', async () => {
|
||||
// VIEWER inherits from GUEST, so should have ME access
|
||||
const result = await enforcer.enforce(Role.VIEWER, Resource.ME, AuthAction.READ_ANY);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should inherit GUEST permissions for CONNECT', async () => {
|
||||
// CONNECT inherits from GUEST, so should have ME access
|
||||
const result = await enforcer.enforce(Role.CONNECT, Resource.ME, AuthAction.READ_ANY);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should inherit GUEST permissions for ADMIN', async () => {
|
||||
// ADMIN inherits from GUEST, so should have ME access
|
||||
const result = await enforcer.enforce(Role.ADMIN, Resource.ME, AuthAction.READ_ANY);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases and security', () => {
|
||||
it('should deny access with empty action', async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
const result = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, '');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should deny access with empty resource', async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
const result = await enforcer.enforce(Role.VIEWER, '', AuthAction.READ_ANY);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should deny access with undefined role', async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
const result = await enforcer.enforce(
|
||||
'UNDEFINED_ROLE',
|
||||
Resource.DOCKER,
|
||||
AuthAction.READ_ANY
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should deny access with malformed action', async () => {
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
const malformedActions = [
|
||||
'read', // Missing possession
|
||||
':any', // Missing verb
|
||||
'read:', // Empty possession
|
||||
'read:own', // Different possession format
|
||||
'READ', // Uppercase without possession
|
||||
];
|
||||
|
||||
for (const action of malformedActions) {
|
||||
const result = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, action);
|
||||
expect(result).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { Model as CasbinModel, newEnforcer, StringAdapter } from 'casbin';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CASBIN_MODEL } from '@app/unraid-api/auth/casbin/model.js';
|
||||
import { BASE_POLICY } from '@app/unraid-api/auth/casbin/policy.js';
|
||||
|
||||
describe('Casbin Policy - VIEWER role restrictions', () => {
|
||||
it('should validate matcher does not allow empty policies', async () => {
|
||||
// Test that empty policies don't match everything
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
|
||||
// Test with a policy that has an empty object
|
||||
const emptyPolicy = `p, VIEWER, , ${AuthAction.READ_ANY}`;
|
||||
const adapter = new StringAdapter(emptyPolicy);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
// Empty policy should not match a real resource
|
||||
const canReadApiKey = await enforcer.enforce(Role.VIEWER, Resource.API_KEY, AuthAction.READ_ANY);
|
||||
expect(canReadApiKey).toBe(false);
|
||||
});
|
||||
|
||||
it('should deny VIEWER role access to API_KEY resource', async () => {
|
||||
// Create enforcer with actual policy
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
// Test that VIEWER cannot access API_KEY with any action
|
||||
const canReadApiKey = await enforcer.enforce(Role.VIEWER, Resource.API_KEY, AuthAction.READ_ANY);
|
||||
const canCreateApiKey = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.API_KEY,
|
||||
AuthAction.CREATE_ANY
|
||||
);
|
||||
const canUpdateApiKey = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.API_KEY,
|
||||
AuthAction.UPDATE_ANY
|
||||
);
|
||||
const canDeleteApiKey = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.API_KEY,
|
||||
AuthAction.DELETE_ANY
|
||||
);
|
||||
|
||||
expect(canReadApiKey).toBe(false);
|
||||
expect(canCreateApiKey).toBe(false);
|
||||
expect(canUpdateApiKey).toBe(false);
|
||||
expect(canDeleteApiKey).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow VIEWER role access to other resources', async () => {
|
||||
// Create enforcer with actual policy
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
// Test that VIEWER can read other resources
|
||||
const canReadDocker = await enforcer.enforce(Role.VIEWER, Resource.DOCKER, AuthAction.READ_ANY);
|
||||
const canReadArray = await enforcer.enforce(Role.VIEWER, Resource.ARRAY, AuthAction.READ_ANY);
|
||||
const canReadConfig = await enforcer.enforce(Role.VIEWER, Resource.CONFIG, AuthAction.READ_ANY);
|
||||
const canReadVms = await enforcer.enforce(Role.VIEWER, Resource.VMS, AuthAction.READ_ANY);
|
||||
|
||||
expect(canReadDocker).toBe(true);
|
||||
expect(canReadArray).toBe(true);
|
||||
expect(canReadConfig).toBe(true);
|
||||
expect(canReadVms).toBe(true);
|
||||
|
||||
// But VIEWER cannot write to these resources
|
||||
const canUpdateDocker = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.DOCKER,
|
||||
AuthAction.UPDATE_ANY
|
||||
);
|
||||
const canDeleteArray = await enforcer.enforce(
|
||||
Role.VIEWER,
|
||||
Resource.ARRAY,
|
||||
AuthAction.DELETE_ANY
|
||||
);
|
||||
|
||||
expect(canUpdateDocker).toBe(false);
|
||||
expect(canDeleteArray).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow ADMIN role full access to API_KEY resource', async () => {
|
||||
// Create enforcer with actual policy
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
// Test that ADMIN can access API_KEY with all actions
|
||||
const canReadApiKey = await enforcer.enforce(Role.ADMIN, Resource.API_KEY, AuthAction.READ_ANY);
|
||||
const canCreateApiKey = await enforcer.enforce(
|
||||
Role.ADMIN,
|
||||
Resource.API_KEY,
|
||||
AuthAction.CREATE_ANY
|
||||
);
|
||||
const canUpdateApiKey = await enforcer.enforce(
|
||||
Role.ADMIN,
|
||||
Resource.API_KEY,
|
||||
AuthAction.UPDATE_ANY
|
||||
);
|
||||
const canDeleteApiKey = await enforcer.enforce(
|
||||
Role.ADMIN,
|
||||
Resource.API_KEY,
|
||||
AuthAction.DELETE_ANY
|
||||
);
|
||||
|
||||
expect(canReadApiKey).toBe(true);
|
||||
expect(canCreateApiKey).toBe(true);
|
||||
expect(canUpdateApiKey).toBe(true);
|
||||
expect(canDeleteApiKey).toBe(true);
|
||||
});
|
||||
|
||||
it('should ensure VIEWER permissions exclude API_KEY in generated policy', () => {
|
||||
// Verify that the generated policy string doesn't contain VIEWER + API_KEY combination
|
||||
expect(BASE_POLICY).toContain(`p, ${Role.VIEWER}, ${Resource.DOCKER}, ${AuthAction.READ_ANY}`);
|
||||
expect(BASE_POLICY).toContain(`p, ${Role.VIEWER}, ${Resource.ARRAY}, ${AuthAction.READ_ANY}`);
|
||||
expect(BASE_POLICY).not.toContain(
|
||||
`p, ${Role.VIEWER}, ${Resource.API_KEY}, ${AuthAction.READ_ANY}`
|
||||
);
|
||||
|
||||
// Count VIEWER permissions - should be total resources minus API_KEY
|
||||
const viewerPermissionLines = BASE_POLICY.split('\n').filter((line) =>
|
||||
line.startsWith(`p, ${Role.VIEWER},`)
|
||||
);
|
||||
const totalResources = Object.values(Resource).length;
|
||||
expect(viewerPermissionLines.length).toBe(totalResources - 1); // All resources except API_KEY
|
||||
});
|
||||
|
||||
it('should inherit GUEST permissions for VIEWER role', async () => {
|
||||
// Create enforcer with actual policy
|
||||
const model = new CasbinModel();
|
||||
model.loadModelFromText(CASBIN_MODEL);
|
||||
const adapter = new StringAdapter(BASE_POLICY);
|
||||
const enforcer = await newEnforcer(model, adapter);
|
||||
|
||||
// VIEWER inherits from GUEST, so should have access to ME resource
|
||||
const canReadMe = await enforcer.enforce(Role.VIEWER, Resource.ME, AuthAction.READ_ANY);
|
||||
expect(canReadMe).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,26 @@
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction } from 'nest-authz';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
|
||||
// Generate VIEWER permissions for all resources except API_KEY
|
||||
const viewerPermissions = Object.values(Resource)
|
||||
.filter((resource) => resource !== Resource.API_KEY)
|
||||
.map((resource) => `p, ${Role.VIEWER}, ${resource}, ${AuthAction.READ_ANY}`)
|
||||
.join('\n');
|
||||
|
||||
export const BASE_POLICY = `
|
||||
# Admin permissions
|
||||
# Admin permissions - full access
|
||||
p, ${Role.ADMIN}, *, *
|
||||
|
||||
# Connect Permissions
|
||||
p, ${Role.CONNECT}, *, ${AuthAction.READ_ANY}
|
||||
# Connect permissions - inherits from VIEWER plus can manage remote access
|
||||
p, ${Role.CONNECT}, ${Resource.CONNECT__REMOTE_ACCESS}, ${AuthAction.UPDATE_ANY}
|
||||
|
||||
# Guest permissions
|
||||
# Guest permissions - basic profile access
|
||||
p, ${Role.GUEST}, ${Resource.ME}, ${AuthAction.READ_ANY}
|
||||
|
||||
# Viewer permissions - read-only access to all resources except API_KEY
|
||||
${viewerPermissions}
|
||||
|
||||
# Role inheritance
|
||||
g, ${Role.ADMIN}, ${Role.GUEST}
|
||||
g, ${Role.CONNECT}, ${Role.GUEST}
|
||||
g, ${Role.CONNECT}, ${Role.VIEWER}
|
||||
g, ${Role.VIEWER}, ${Role.GUEST}
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthActionVerb } from 'nest-authz';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { Command, CommandRunner, InquirerService, Option } from 'nest-commander';
|
||||
|
||||
import type { DeleteApiKeyAnswers } from '@app/unraid-api/cli/apikey/delete-api-key.questions.js';
|
||||
@@ -75,7 +74,7 @@ export class ApiKeyCommand extends CommandRunner {
|
||||
flags: '-p, --permissions <permissions>',
|
||||
description: `Comma separated list of permissions to assign to the key (in the form of "resource:action")
|
||||
RESOURCES: ${Object.values(Resource).join(', ')}
|
||||
ACTIONS: ${Object.values(AuthActionVerb).join(', ')}`,
|
||||
ACTIONS: ${Object.values(AuthAction).join(', ')}`,
|
||||
})
|
||||
parsePermissions(permissions: string): Array<Permission> {
|
||||
return this.apiKeyService.convertPermissionsStringArrayToPermissions(
|
||||
|
||||
@@ -120,7 +120,7 @@ export type ActivationCode = {
|
||||
};
|
||||
|
||||
export type AddPermissionInput = {
|
||||
actions: Array<Scalars['String']['input']>;
|
||||
actions: Array<AuthAction>;
|
||||
resource: Resource;
|
||||
};
|
||||
|
||||
@@ -143,24 +143,36 @@ export type ApiKey = Node & {
|
||||
createdAt: Scalars['String']['output'];
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
key: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
permissions: Array<Permission>;
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
export type ApiKeyFormSettings = FormSchema & Node & {
|
||||
__typename?: 'ApiKeyFormSettings';
|
||||
/** The data schema for the API key form */
|
||||
dataSchema: Scalars['JSON']['output'];
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** The UI schema for the API key form */
|
||||
uiSchema: Scalars['JSON']['output'];
|
||||
/** The current values of the API key form */
|
||||
values: Scalars['JSON']['output'];
|
||||
};
|
||||
|
||||
/** API Key related mutations */
|
||||
export type ApiKeyMutations = {
|
||||
__typename?: 'ApiKeyMutations';
|
||||
/** Add a role to an API key */
|
||||
addRole: Scalars['Boolean']['output'];
|
||||
/** Create an API key */
|
||||
create: ApiKeyWithSecret;
|
||||
create: ApiKey;
|
||||
/** Delete one or more API keys */
|
||||
delete: Scalars['Boolean']['output'];
|
||||
/** Remove a role from an API key */
|
||||
removeRole: Scalars['Boolean']['output'];
|
||||
/** Update an API key */
|
||||
update: ApiKeyWithSecret;
|
||||
update: ApiKey;
|
||||
};
|
||||
|
||||
|
||||
@@ -199,17 +211,6 @@ export type ApiKeyResponse = {
|
||||
valid: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type ApiKeyWithSecret = Node & {
|
||||
__typename?: 'ApiKeyWithSecret';
|
||||
createdAt: Scalars['String']['output'];
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
key: Scalars['String']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
permissions: Array<Permission>;
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
export type ArrayCapacity = {
|
||||
__typename?: 'ArrayCapacity';
|
||||
/** Capacity in number of disks */
|
||||
@@ -370,19 +371,24 @@ export enum ArrayStateInputState {
|
||||
STOP = 'STOP'
|
||||
}
|
||||
|
||||
/** Available authentication action verbs */
|
||||
export enum AuthActionVerb {
|
||||
CREATE = 'CREATE',
|
||||
DELETE = 'DELETE',
|
||||
READ = 'READ',
|
||||
UPDATE = 'UPDATE'
|
||||
}
|
||||
|
||||
/** Available authentication possession types */
|
||||
export enum AuthPossession {
|
||||
ANY = 'ANY',
|
||||
OWN = 'OWN',
|
||||
OWN_ANY = 'OWN_ANY'
|
||||
/** Authentication actions with possession (e.g., create:any, read:own) */
|
||||
export enum AuthAction {
|
||||
/** Create any resource */
|
||||
CREATE_ANY = 'CREATE_ANY',
|
||||
/** Create own resource */
|
||||
CREATE_OWN = 'CREATE_OWN',
|
||||
/** Delete any resource */
|
||||
DELETE_ANY = 'DELETE_ANY',
|
||||
/** Delete own resource */
|
||||
DELETE_OWN = 'DELETE_OWN',
|
||||
/** Read any resource */
|
||||
READ_ANY = 'READ_ANY',
|
||||
/** Read own resource */
|
||||
READ_OWN = 'READ_OWN',
|
||||
/** Update any resource */
|
||||
UPDATE_ANY = 'UPDATE_ANY',
|
||||
/** Update own resource */
|
||||
UPDATE_OWN = 'UPDATE_OWN'
|
||||
}
|
||||
|
||||
/** Operators for authorization rule matching */
|
||||
@@ -776,6 +782,15 @@ export type FlashBackupStatus = {
|
||||
status: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type FormSchema = {
|
||||
/** The data schema for the form */
|
||||
dataSchema: Scalars['JSON']['output'];
|
||||
/** The UI schema for the form */
|
||||
uiSchema: Scalars['JSON']['output'];
|
||||
/** The current values of the form */
|
||||
values: Scalars['JSON']['output'];
|
||||
};
|
||||
|
||||
export type Info = Node & {
|
||||
__typename?: 'Info';
|
||||
/** Motherboard information */
|
||||
@@ -1053,7 +1068,7 @@ export type InfoVersions = Node & {
|
||||
core: CoreVersions;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Software package versions */
|
||||
packages: PackageVersions;
|
||||
packages?: Maybe<PackageVersions>;
|
||||
};
|
||||
|
||||
export type InitiateFlashBackupInput = {
|
||||
@@ -1519,7 +1534,7 @@ export type ParityCheck = {
|
||||
/** Speed of the parity check, in MB/s */
|
||||
speed?: Maybe<Scalars['String']['output']>;
|
||||
/** Status of the parity check */
|
||||
status?: Maybe<Scalars['String']['output']>;
|
||||
status: ParityCheckStatus;
|
||||
};
|
||||
|
||||
/** Parity check related mutations, WIP, response types and functionaliy will change */
|
||||
@@ -1541,9 +1556,19 @@ export type ParityCheckMutationsStartArgs = {
|
||||
correct: Scalars['Boolean']['input'];
|
||||
};
|
||||
|
||||
export enum ParityCheckStatus {
|
||||
CANCELLED = 'CANCELLED',
|
||||
COMPLETED = 'COMPLETED',
|
||||
FAILED = 'FAILED',
|
||||
NEVER_RUN = 'NEVER_RUN',
|
||||
PAUSED = 'PAUSED',
|
||||
RUNNING = 'RUNNING'
|
||||
}
|
||||
|
||||
export type Permission = {
|
||||
__typename?: 'Permission';
|
||||
actions: Array<Scalars['String']['output']>;
|
||||
/** Actions allowed on this resource */
|
||||
actions: Array<AuthAction>;
|
||||
resource: Resource;
|
||||
};
|
||||
|
||||
@@ -1613,6 +1638,12 @@ export type Query = {
|
||||
disks: Array<Disk>;
|
||||
docker: Docker;
|
||||
flash: Flash;
|
||||
/** Get JSON Schema for API key creation form */
|
||||
getApiKeyCreationFormSchema: ApiKeyFormSettings;
|
||||
/** Get all available authentication actions with possession */
|
||||
getAvailableAuthActions: Array<AuthAction>;
|
||||
/** Get the actual permissions that would be granted by a set of roles */
|
||||
getPermissionsForRoles: Array<Permission>;
|
||||
info: Info;
|
||||
isInitialSetup: Scalars['Boolean']['output'];
|
||||
isSSOEnabled: Scalars['Boolean']['output'];
|
||||
@@ -1632,6 +1663,8 @@ export type Query = {
|
||||
parityHistory: Array<ParityCheck>;
|
||||
/** List all installed plugins with their metadata */
|
||||
plugins: Array<Plugin>;
|
||||
/** Preview the effective permissions for a combination of roles and explicit permissions */
|
||||
previewEffectivePermissions: Array<Permission>;
|
||||
/** Get public OIDC provider information for login buttons */
|
||||
publicOidcProviders: Array<PublicOidcProvider>;
|
||||
publicPartnerInfo?: Maybe<PublicPartnerInfo>;
|
||||
@@ -1665,6 +1698,11 @@ export type QueryDiskArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryGetPermissionsForRolesArgs = {
|
||||
roles: Array<Role>;
|
||||
};
|
||||
|
||||
|
||||
export type QueryLogFileArgs = {
|
||||
lines?: InputMaybe<Scalars['Int']['input']>;
|
||||
path: Scalars['String']['input'];
|
||||
@@ -1677,6 +1715,12 @@ export type QueryOidcProviderArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QueryPreviewEffectivePermissionsArgs = {
|
||||
permissions?: InputMaybe<Array<AddPermissionInput>>;
|
||||
roles?: InputMaybe<Array<Role>>;
|
||||
};
|
||||
|
||||
|
||||
export type QueryUpsDeviceByIdArgs = {
|
||||
id: Scalars['String']['input'];
|
||||
};
|
||||
@@ -1869,10 +1913,14 @@ export enum Resource {
|
||||
|
||||
/** Available roles for API keys and users */
|
||||
export enum Role {
|
||||
/** Full administrative access to all resources */
|
||||
ADMIN = 'ADMIN',
|
||||
/** Internal Role for Unraid Connect */
|
||||
CONNECT = 'CONNECT',
|
||||
/** Basic read access to user profile only */
|
||||
GUEST = 'GUEST',
|
||||
USER = 'USER'
|
||||
/** Read-only access to all resources */
|
||||
VIEWER = 'VIEWER'
|
||||
}
|
||||
|
||||
export type Server = Node & {
|
||||
@@ -2149,7 +2197,7 @@ export enum UrlType {
|
||||
WIREGUARD = 'WIREGUARD'
|
||||
}
|
||||
|
||||
export type UnifiedSettings = Node & {
|
||||
export type UnifiedSettings = FormSchema & Node & {
|
||||
__typename?: 'UnifiedSettings';
|
||||
/** The data schema for the settings */
|
||||
dataSchema: Scalars['JSON']['output'];
|
||||
@@ -2173,6 +2221,8 @@ export type UnraidArray = Node & {
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Parity disks in the current array */
|
||||
parities: Array<ArrayDisk>;
|
||||
/** Current parity check status */
|
||||
parityCheckStatus: ParityCheck;
|
||||
/** Current array state */
|
||||
state: ArrayState;
|
||||
};
|
||||
@@ -2527,7 +2577,7 @@ export type GetSsoUsersQuery = { __typename?: 'Query', settings: { __typename?:
|
||||
export type SystemReportQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type SystemReportQuery = { __typename?: 'Query', info: { __typename?: 'Info', id: any, machineId?: string | null, system: { __typename?: 'InfoSystem', manufacturer?: string | null, model?: string | null, version?: string | null, sku?: string | null, serial?: string | null, uuid?: string | null }, versions: { __typename?: 'InfoVersions', core: { __typename?: 'CoreVersions', unraid?: string | null, kernel?: string | null }, packages: { __typename?: 'PackageVersions', openssl?: string | null } } }, config: { __typename?: 'Config', id: any, valid?: boolean | null, error?: string | null }, server?: { __typename?: 'Server', id: any, name: string } | null };
|
||||
export type SystemReportQuery = { __typename?: 'Query', info: { __typename?: 'Info', id: any, machineId?: string | null, system: { __typename?: 'InfoSystem', manufacturer?: string | null, model?: string | null, version?: string | null, sku?: string | null, serial?: string | null, uuid?: string | null }, versions: { __typename?: 'InfoVersions', core: { __typename?: 'CoreVersions', unraid?: string | null, kernel?: string | null }, packages?: { __typename?: 'PackageVersions', openssl?: string | null } | null } }, config: { __typename?: 'Config', id: any, valid?: boolean | null, error?: string | null }, server?: { __typename?: 'Server', id: any, name: string } | null };
|
||||
|
||||
export type ConnectStatusQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// All enum registrations have been moved to @unraid/shared/graphql.model.js
|
||||
// Just re-export AuthAction for convenience
|
||||
export { AuthAction } from '@unraid/shared/graphql.model.js';
|
||||
@@ -1,52 +1,3 @@
|
||||
import { DirectiveLocation, GraphQLDirective, GraphQLEnumType, GraphQLString } from 'graphql';
|
||||
import { AuthActionVerb, AuthPossession } from 'nest-authz';
|
||||
|
||||
// Create GraphQL enum types for auth action verbs and possessions
|
||||
export const AuthActionVerbEnum = new GraphQLEnumType({
|
||||
name: 'AuthActionVerb',
|
||||
description: 'Available authentication action verbs',
|
||||
values: Object.entries(AuthActionVerb)
|
||||
.filter(([key]) => isNaN(Number(key))) // Filter out numeric keys
|
||||
.reduce(
|
||||
(acc, [key]) => {
|
||||
acc[key] = { value: key };
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { value: string }>
|
||||
),
|
||||
});
|
||||
|
||||
export const AuthPossessionEnum = new GraphQLEnumType({
|
||||
name: 'AuthPossession',
|
||||
description: 'Available authentication possession types',
|
||||
values: Object.entries(AuthPossession)
|
||||
.filter(([key]) => isNaN(Number(key))) // Filter out numeric keys
|
||||
.reduce(
|
||||
(acc, [key]) => {
|
||||
acc[key] = { value: key };
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { value: string }>
|
||||
),
|
||||
});
|
||||
|
||||
// Create the auth directive
|
||||
export const AuthDirective = new GraphQLDirective({
|
||||
name: 'auth',
|
||||
description: 'Directive to control access to fields based on authentication',
|
||||
locations: [DirectiveLocation.FIELD_DEFINITION],
|
||||
args: {
|
||||
action: {
|
||||
type: AuthActionVerbEnum,
|
||||
description: 'The action verb required for access',
|
||||
},
|
||||
resource: {
|
||||
type: GraphQLString,
|
||||
description: 'The resource required for access',
|
||||
},
|
||||
possession: {
|
||||
type: AuthPossessionEnum,
|
||||
description: 'The possession type required for access',
|
||||
},
|
||||
},
|
||||
});
|
||||
// Resource and Role enums are already registered in @unraid/shared/graphql.model.js
|
||||
// Just re-export them here for convenience
|
||||
export { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
|
||||
@@ -12,6 +12,10 @@ import { NoUnusedVariablesRule } from 'graphql';
|
||||
|
||||
import { ENVIRONMENT } from '@app/environment.js';
|
||||
import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js';
|
||||
|
||||
// Import enum registrations to ensure they're registered with GraphQL
|
||||
import '@app/unraid-api/graph/auth/auth-action.enum.js';
|
||||
|
||||
import { createDynamicIntrospectionPlugin } from '@app/unraid-api/graph/introspection-plugin.js';
|
||||
import { ResolversModule } from '@app/unraid-api/graph/resolvers/resolvers.module.js';
|
||||
import { createSandboxPlugin } from '@app/unraid-api/graph/sandbox-plugin.js';
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { ApiKeyFormService } from '@app/unraid-api/graph/resolvers/api-key/api-key-form.service.js';
|
||||
import { ApiKeyFormSettings } from '@app/unraid-api/graph/resolvers/settings/settings.model.js';
|
||||
|
||||
@Injectable()
|
||||
@Resolver()
|
||||
export class ApiKeyFormResolver {
|
||||
constructor(private apiKeyFormService: ApiKeyFormService) {}
|
||||
|
||||
@Query(() => ApiKeyFormSettings, {
|
||||
description: 'Get JSON Schema for API key creation form',
|
||||
})
|
||||
@UsePermissions({
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
})
|
||||
getApiKeyCreationFormSchema(): ApiKeyFormSettings {
|
||||
return this.apiKeyFormService.getApiKeyCreationFormSchema();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
ApiKeyFormData,
|
||||
ApiKeyFormService,
|
||||
} from '@app/unraid-api/graph/resolvers/api-key/api-key-form.service.js';
|
||||
|
||||
describe('ApiKeyFormService', () => {
|
||||
let service: ApiKeyFormService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new ApiKeyFormService();
|
||||
});
|
||||
|
||||
describe('convertFormDataToPermissions', () => {
|
||||
describe('basic functionality', () => {
|
||||
it('should merge roles and custom permissions', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
roles: [Role.ADMIN],
|
||||
customPermissions: [
|
||||
{
|
||||
resources: [Resource.NETWORK],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.roles).toEqual([Role.ADMIN]);
|
||||
expect(result.permissions).toContainEqual({
|
||||
resource: Resource.NETWORK,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle only roles when others are not provided', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
roles: [Role.GUEST, Role.VIEWER],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.roles).toEqual([Role.GUEST, Role.VIEWER]);
|
||||
expect(result.permissions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle multiple roles', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
roles: [Role.GUEST, Role.VIEWER, Role.ADMIN],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.roles).toEqual([Role.GUEST, Role.VIEWER, Role.ADMIN]);
|
||||
expect(result.permissions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle only custom permissions when others are not provided', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
customPermissions: [
|
||||
{
|
||||
resources: [Resource.ARRAY, Resource.DISK],
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.roles).toEqual([]);
|
||||
expect(result.permissions).toContainEqual({
|
||||
resource: Resource.ARRAY,
|
||||
actions: expect.arrayContaining([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]),
|
||||
});
|
||||
expect(result.permissions).toContainEqual({
|
||||
resource: Resource.DISK,
|
||||
actions: expect.arrayContaining([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty form data', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.roles).toEqual([]);
|
||||
expect(result.permissions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom permissions handling', () => {
|
||||
it('should merge custom permissions with same resource', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
customPermissions: [
|
||||
{
|
||||
resources: [Resource.DOCKER],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
{
|
||||
resources: [Resource.DOCKER],
|
||||
actions: [AuthAction.UPDATE_ANY, AuthAction.DELETE_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.permissions).toEqual([
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: expect.arrayContaining([
|
||||
AuthAction.READ_ANY,
|
||||
AuthAction.UPDATE_ANY,
|
||||
AuthAction.DELETE_ANY,
|
||||
]),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should deduplicate actions when merging', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
customPermissions: [
|
||||
{
|
||||
resources: [Resource.NETWORK],
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
{
|
||||
resources: [Resource.NETWORK],
|
||||
actions: [AuthAction.READ_ANY, AuthAction.DELETE_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
const networkPermission = result.permissions.find(
|
||||
(p) => p.resource === Resource.NETWORK
|
||||
);
|
||||
expect(networkPermission?.actions).toHaveLength(3);
|
||||
expect(networkPermission?.actions).toContain(AuthAction.READ_ANY);
|
||||
expect(networkPermission?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
expect(networkPermission?.actions).toContain(AuthAction.DELETE_ANY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle resources as non-array in custom permissions', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
customPermissions: [
|
||||
{
|
||||
resources: Resource.DOCKER as any,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.permissions).toEqual([
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle actions as non-array in custom permissions', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
customPermissions: [
|
||||
{
|
||||
resources: [Resource.DOCKER],
|
||||
actions: AuthAction.READ_ANY as any,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.permissions).toEqual([
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle empty arrays gracefully', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
roles: [],
|
||||
customPermissions: [],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.roles).toEqual([]);
|
||||
expect(result.permissions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle both roles and custom permissions together', () => {
|
||||
const formData: ApiKeyFormData = {
|
||||
name: 'Test Key',
|
||||
roles: [Role.VIEWER],
|
||||
customPermissions: [
|
||||
{
|
||||
resources: [Resource.DOCKER, Resource.VMS],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
{
|
||||
resources: [Resource.NETWORK],
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = service.convertFormDataToPermissions(formData);
|
||||
|
||||
expect(result.roles).toEqual([Role.VIEWER]);
|
||||
expect(result.permissions).toHaveLength(3);
|
||||
expect(result.permissions).toContainEqual({
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
});
|
||||
expect(result.permissions).toContainEqual({
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
});
|
||||
expect(result.permissions).toContainEqual({
|
||||
resource: Resource.NETWORK,
|
||||
actions: expect.arrayContaining([AuthAction.READ_ANY, AuthAction.UPDATE_ANY]),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { JsonSchema, LabelElement, UISchemaElement } from '@jsonforms/core';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { mergeSettingSlices } from '@unraid/shared/jsonforms/settings.js';
|
||||
import { normalizeAction } from '@unraid/shared/util/permissions.js';
|
||||
import { capitalCase } from 'change-case';
|
||||
|
||||
import type { SettingSlice } from '@app/unraid-api/types/json-forms.js';
|
||||
import {
|
||||
createLabeledControl,
|
||||
createSimpleLabeledControl,
|
||||
} from '@app/unraid-api/graph/utils/form-utils.js';
|
||||
|
||||
// Helper to get GraphQL enum names for JSON Schema
|
||||
// GraphQL expects the enum names (keys) not the values
|
||||
function getAuthActionEnumNames(): string[] {
|
||||
// Get only the "_ANY" actions (not "_OWN")
|
||||
// e.g., CREATE_ANY, READ_ANY, UPDATE_ANY, DELETE_ANY
|
||||
return Object.keys(AuthAction).filter((key) => key === key.toUpperCase() && key.endsWith('_ANY'));
|
||||
}
|
||||
|
||||
// Helper to create labels for AuthAction enum dynamically
|
||||
function getAuthActionLabels(): Record<string, string> {
|
||||
const labels: Record<string, string> = {};
|
||||
|
||||
for (const enumName of getAuthActionEnumNames()) {
|
||||
// Convert CREATE_ANY -> Create (All)
|
||||
// Convert READ_OWN -> Read (Own)
|
||||
const [verb, possession] = enumName.split('_');
|
||||
const verbLabel = capitalCase(verb.toLowerCase());
|
||||
const possessionLabel = possession === 'ANY' ? 'All' : 'Own';
|
||||
labels[enumName] = `${verbLabel} (${possessionLabel})`;
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
export interface ApiKeyFormData {
|
||||
name: string;
|
||||
description?: string;
|
||||
roles?: Role[];
|
||||
permissionPresets?: string; // Single preset selection from dropdown
|
||||
customPermissions?: Array<{
|
||||
resources: Resource[]; // Form uses array for multi-select
|
||||
actions: string[];
|
||||
}>;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyFormService {
|
||||
/**
|
||||
* Generate form schema for API key creation
|
||||
*/
|
||||
getApiKeyCreationFormSchema(): {
|
||||
id: string;
|
||||
dataSchema: Record<string, any>;
|
||||
uiSchema: Record<string, any>;
|
||||
values: Record<string, any>;
|
||||
} {
|
||||
const slice = this.createApiKeyCreationSlice();
|
||||
const merged = mergeSettingSlices([slice]);
|
||||
|
||||
return {
|
||||
id: 'api-key-creation-form',
|
||||
dataSchema: {
|
||||
type: 'object',
|
||||
required: ['name'],
|
||||
properties: merged.properties,
|
||||
},
|
||||
uiSchema: {
|
||||
type: 'VerticalLayout',
|
||||
elements: merged.elements,
|
||||
},
|
||||
values: {},
|
||||
};
|
||||
}
|
||||
|
||||
private createApiKeyCreationSlice(): SettingSlice {
|
||||
const slice: SettingSlice = {
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
title: 'API Key Name',
|
||||
description: 'A descriptive name for this API key',
|
||||
minLength: 1,
|
||||
maxLength: 100,
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
title: 'Description',
|
||||
description: 'Optional description of what this key is used for',
|
||||
maxLength: 500,
|
||||
},
|
||||
roles: {
|
||||
type: 'array',
|
||||
title: 'Roles',
|
||||
description: 'Select one or more roles to grant pre-defined permission sets',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: this.getAvailableRoles(),
|
||||
},
|
||||
uniqueItems: true,
|
||||
},
|
||||
permissionPresets: {
|
||||
type: 'string',
|
||||
title: 'Add Permission Preset',
|
||||
description: 'Quick add common permission sets',
|
||||
enum: [
|
||||
'none',
|
||||
'docker_manager',
|
||||
'vm_manager',
|
||||
'monitoring',
|
||||
'backup_manager',
|
||||
'network_admin',
|
||||
],
|
||||
default: 'none',
|
||||
},
|
||||
customPermissions: {
|
||||
type: 'array',
|
||||
title: 'Permissions',
|
||||
description: 'Configure specific permissions',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
resources: {
|
||||
type: 'array',
|
||||
title: 'Resources',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: this.getAvailableResources(),
|
||||
},
|
||||
uniqueItems: true,
|
||||
minItems: 1,
|
||||
default: [this.getAvailableResources()[0]], // Set a default value as array
|
||||
},
|
||||
actions: {
|
||||
type: 'array',
|
||||
title: 'Actions',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: getAuthActionEnumNames(),
|
||||
},
|
||||
uniqueItems: true,
|
||||
minItems: 1,
|
||||
default: ['READ_ANY'], // Set a default action
|
||||
},
|
||||
},
|
||||
required: ['resources', 'actions'],
|
||||
},
|
||||
},
|
||||
// Commenting out expiration date until date picker is implemented
|
||||
// expiresAt: {
|
||||
// type: 'string',
|
||||
// format: 'date-time',
|
||||
// title: 'Expiration Date',
|
||||
// description: 'Optional expiration date for this API key',
|
||||
// },
|
||||
},
|
||||
elements: [
|
||||
createLabeledControl({
|
||||
scope: '#/properties/name',
|
||||
label: 'API Key Name',
|
||||
description: 'A descriptive name for this API key',
|
||||
layoutType: 'VerticalLayout',
|
||||
controlOptions: {
|
||||
inputType: 'text',
|
||||
},
|
||||
}),
|
||||
createLabeledControl({
|
||||
scope: '#/properties/description',
|
||||
label: 'Description',
|
||||
description: 'Optional description of what this key is used for',
|
||||
layoutType: 'VerticalLayout',
|
||||
controlOptions: {
|
||||
multi: true,
|
||||
rows: 3,
|
||||
},
|
||||
}),
|
||||
// Permissions section header
|
||||
{
|
||||
type: 'Label',
|
||||
text: 'Permissions Configuration',
|
||||
options: {
|
||||
format: 'title',
|
||||
},
|
||||
} as LabelElement,
|
||||
{
|
||||
type: 'Label',
|
||||
text: 'Select any combination of roles, permission groups, and custom permissions to define what this API key can access.',
|
||||
options: {
|
||||
format: 'description',
|
||||
},
|
||||
} as LabelElement,
|
||||
// Roles selection
|
||||
createLabeledControl({
|
||||
scope: '#/properties/roles',
|
||||
label: 'Roles',
|
||||
description: 'Select one or more roles to grant pre-defined permission sets',
|
||||
layoutType: 'VerticalLayout',
|
||||
controlOptions: {
|
||||
multiple: true,
|
||||
labels: this.getAvailableRoles().reduce(
|
||||
(acc, role) => ({
|
||||
...acc,
|
||||
[role]: capitalCase(role),
|
||||
}),
|
||||
{}
|
||||
),
|
||||
descriptions: this.getRoleDescriptions(),
|
||||
},
|
||||
}),
|
||||
// Separator for permissions
|
||||
{
|
||||
type: 'Label',
|
||||
text: 'Permissions',
|
||||
options: {
|
||||
format: 'subtitle',
|
||||
},
|
||||
} as LabelElement,
|
||||
{
|
||||
type: 'Label',
|
||||
text: 'Use the preset dropdown for common permission sets, or manually add custom permissions. You can select multiple resources that share the same actions.',
|
||||
options: {
|
||||
format: 'description',
|
||||
},
|
||||
} as LabelElement,
|
||||
// Permission preset dropdown
|
||||
createLabeledControl({
|
||||
scope: '#/properties/permissionPresets',
|
||||
label: 'Quick Add Presets',
|
||||
description: 'Select a preset to quickly add common permission sets',
|
||||
layoutType: 'VerticalLayout',
|
||||
controlOptions: {
|
||||
labels: {
|
||||
none: '-- Select a preset --',
|
||||
docker_manager: 'Docker Manager (Full Docker Control)',
|
||||
vm_manager: 'VM Manager (Full VM Control)',
|
||||
monitoring: 'Monitoring (Read-only System Info)',
|
||||
backup_manager: 'Backup Manager (Flash & Share Control)',
|
||||
network_admin: 'Network Admin (Network & Services Control)',
|
||||
},
|
||||
},
|
||||
}),
|
||||
// Custom permissions array - following OIDC pattern exactly
|
||||
{
|
||||
type: 'Control',
|
||||
scope: '#/properties/customPermissions',
|
||||
options: {
|
||||
elementLabelFormat: 'Permission Entry',
|
||||
itemTypeName: 'Permission',
|
||||
detail: {
|
||||
type: 'VerticalLayout',
|
||||
elements: [
|
||||
createSimpleLabeledControl({
|
||||
scope: '#/properties/resources',
|
||||
label: 'Resources:',
|
||||
description: 'Select the resources to grant permissions for',
|
||||
controlOptions: {
|
||||
multiple: true,
|
||||
labels: this.getAvailableResources().reduce(
|
||||
(acc, resource) => ({
|
||||
...acc,
|
||||
[resource]: capitalCase(
|
||||
resource.toLowerCase().replace(/_/g, ' ')
|
||||
),
|
||||
}),
|
||||
{}
|
||||
),
|
||||
},
|
||||
}),
|
||||
createSimpleLabeledControl({
|
||||
scope: '#/properties/actions',
|
||||
label: 'Actions:',
|
||||
description: 'Select the actions allowed on this resource',
|
||||
controlOptions: {
|
||||
multiple: true,
|
||||
labels: getAuthActionLabels(),
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
} as UISchemaElement,
|
||||
// Note: Datetime inputs are not currently supported in the renderer
|
||||
// Would need to implement a date picker component
|
||||
// For now, commenting out the expiration date field
|
||||
// createLabeledControl({
|
||||
// scope: '#/properties/expiresAt',
|
||||
// label: 'Expiration Date:',
|
||||
// description: 'Optional expiration date for this API key',
|
||||
// controlOptions: {
|
||||
// inputType: 'datetime-local',
|
||||
// },
|
||||
// }),
|
||||
],
|
||||
};
|
||||
|
||||
return slice;
|
||||
}
|
||||
|
||||
private getAvailableRoles(): Role[] {
|
||||
return [Role.ADMIN, Role.VIEWER, Role.CONNECT, Role.GUEST];
|
||||
}
|
||||
|
||||
private getRoleDescriptions(): Record<Role, string> {
|
||||
return {
|
||||
[Role.ADMIN]: 'Full administrative access to all resources',
|
||||
[Role.VIEWER]: 'Read-only access to all resources',
|
||||
[Role.CONNECT]: 'Internal Role for Unraid Connect',
|
||||
[Role.GUEST]: 'Basic read access to user profile only',
|
||||
};
|
||||
}
|
||||
|
||||
private getAvailableResources(): Resource[] {
|
||||
return Object.values(Resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert form data back to permissions for API key creation
|
||||
* The form provides: name, description, roles, and customPermissions
|
||||
* Note: permissionPresets is only a UI helper that adds to customPermissions
|
||||
*/
|
||||
convertFormDataToPermissions(formData: ApiKeyFormData): {
|
||||
roles: Role[];
|
||||
permissions: Array<{ resource: Resource; actions: AuthAction[] }>;
|
||||
} {
|
||||
const roles: Role[] = [];
|
||||
const permissions = new Map<Resource, Set<AuthAction>>();
|
||||
|
||||
// 1. Add roles if provided
|
||||
if (formData.roles && formData.roles.length > 0) {
|
||||
roles.push(...formData.roles);
|
||||
}
|
||||
|
||||
// 2. Add custom permissions if provided
|
||||
// This includes permissions added via the preset dropdown
|
||||
if (formData.customPermissions && formData.customPermissions.length > 0) {
|
||||
for (const perm of formData.customPermissions) {
|
||||
// Handle resources as an array (form uses multi-select)
|
||||
const resources = Array.isArray(perm.resources)
|
||||
? perm.resources
|
||||
: [perm.resources as Resource];
|
||||
|
||||
// Handle actions as an array and normalize them
|
||||
const rawActions = Array.isArray(perm.actions) ? perm.actions : [perm.actions];
|
||||
const normalizedActions: AuthAction[] = [];
|
||||
|
||||
for (const rawAction of rawActions) {
|
||||
const normalized = normalizeAction(rawAction);
|
||||
if (normalized) {
|
||||
normalizedActions.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
for (const resource of resources) {
|
||||
if (!permissions.has(resource)) {
|
||||
permissions.set(resource, new Set());
|
||||
}
|
||||
normalizedActions.forEach((action) => permissions.get(resource)!.add(action));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
roles,
|
||||
permissions: Array.from(permissions.entries()).map(([resource, actions]) => ({
|
||||
resource,
|
||||
actions: Array.from(actions),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Args, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
import {
|
||||
expandWildcardAction,
|
||||
mergePermissionsIntoMap,
|
||||
parseActionToAuthAction,
|
||||
} from '@unraid/shared/util/permissions.js';
|
||||
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import {
|
||||
AddPermissionInput,
|
||||
Permission,
|
||||
} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
|
||||
@Injectable()
|
||||
@Resolver()
|
||||
export class ApiKeyPermissionsResolver {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Query(() => [Permission], {
|
||||
description: 'Get the actual permissions that would be granted by a set of roles',
|
||||
})
|
||||
@UsePermissions({
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.PERMISSION,
|
||||
})
|
||||
async getPermissionsForRoles(
|
||||
@Args('roles', { type: () => [Role] }) roles: Role[]
|
||||
): Promise<Permission[]> {
|
||||
// Get the implicit permissions for each role from Casbin
|
||||
const allPermissions = new Map<Resource, Set<AuthAction>>();
|
||||
|
||||
for (const role of roles) {
|
||||
// Query Casbin for what permissions this role actually has
|
||||
const rolePermissions = await this.authService.getImplicitPermissionsForRole(role);
|
||||
mergePermissionsIntoMap(allPermissions, rolePermissions);
|
||||
}
|
||||
|
||||
// Convert to Permission array
|
||||
const permissions: Permission[] = [];
|
||||
for (const [resource, actions] of allPermissions) {
|
||||
permissions.push({
|
||||
resource,
|
||||
actions: Array.from(actions),
|
||||
});
|
||||
}
|
||||
|
||||
return permissions;
|
||||
}
|
||||
|
||||
@Query(() => [Permission], {
|
||||
description:
|
||||
'Preview the effective permissions for a combination of roles and explicit permissions',
|
||||
})
|
||||
@UsePermissions({
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.PERMISSION,
|
||||
})
|
||||
async previewEffectivePermissions(
|
||||
@Args('roles', { type: () => [Role], nullable: true }) roles?: Role[],
|
||||
@Args('permissions', { type: () => [AddPermissionInput], nullable: true })
|
||||
permissions?: AddPermissionInput[]
|
||||
): Promise<Permission[]> {
|
||||
const effectivePermissions = new Map<Resource, Set<AuthAction>>();
|
||||
|
||||
// Add permissions from roles
|
||||
for (const role of roles ?? []) {
|
||||
const rolePermissions = await this.authService.getImplicitPermissionsForRole(role);
|
||||
mergePermissionsIntoMap(effectivePermissions, rolePermissions);
|
||||
}
|
||||
|
||||
// Add explicit permissions
|
||||
if (permissions && permissions.length > 0) {
|
||||
for (const perm of permissions) {
|
||||
if (!effectivePermissions.has(perm.resource)) {
|
||||
effectivePermissions.set(perm.resource, new Set());
|
||||
}
|
||||
const resourceActions = effectivePermissions.get(perm.resource)!;
|
||||
|
||||
perm.actions.forEach((action) => {
|
||||
const actionStr = String(action);
|
||||
|
||||
// Handle wildcard - expand to all CRUD actions
|
||||
if (actionStr === '*' || actionStr.toLowerCase() === '*') {
|
||||
expandWildcardAction().forEach((expandedAction) => {
|
||||
resourceActions.add(expandedAction);
|
||||
});
|
||||
} else {
|
||||
// Use the shared helper to parse and validate the action
|
||||
const parsedAction = parseActionToAuthAction(actionStr);
|
||||
if (parsedAction) {
|
||||
resourceActions.add(parsedAction);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to Permission array
|
||||
const result: Permission[] = [];
|
||||
for (const [resource, actions] of effectivePermissions) {
|
||||
result.push({
|
||||
resource,
|
||||
actions: Array.from(actions),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Query(() => [AuthAction], {
|
||||
description: 'Get all available authentication actions with possession',
|
||||
})
|
||||
getAvailableAuthActions(): AuthAction[] {
|
||||
return Object.values(AuthAction);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Field, InputType, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { Node, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Node, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
@@ -22,15 +22,21 @@ export class Permission {
|
||||
@IsEnum(Resource)
|
||||
resource!: Resource;
|
||||
|
||||
@Field(() => [String])
|
||||
@Field(() => [AuthAction], {
|
||||
description: 'Actions allowed on this resource',
|
||||
})
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsEnum(AuthAction, { each: true })
|
||||
@ArrayMinSize(1)
|
||||
actions!: string[];
|
||||
actions!: AuthAction[];
|
||||
}
|
||||
|
||||
@ObjectType({ implements: () => Node })
|
||||
export class ApiKey extends Node {
|
||||
@Field()
|
||||
@IsString()
|
||||
key!: string;
|
||||
|
||||
@Field()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@@ -58,24 +64,17 @@ export class ApiKey extends Node {
|
||||
permissions!: Permission[];
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class ApiKeyWithSecret extends ApiKey {
|
||||
@Field()
|
||||
@IsString()
|
||||
key!: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class AddPermissionInput {
|
||||
@Field(() => Resource)
|
||||
@IsEnum(Resource)
|
||||
resource!: Resource;
|
||||
|
||||
@Field(() => [String])
|
||||
@Field(() => [AuthAction])
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsEnum(AuthAction, { each: true })
|
||||
@ArrayMinSize(1)
|
||||
actions!: string[];
|
||||
actions!: AuthAction[];
|
||||
}
|
||||
|
||||
@InputType()
|
||||
|
||||
@@ -3,12 +3,23 @@ import { Module } from '@nestjs/common';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AuthModule } from '@app/unraid-api/auth/auth.module.js';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { ApiKeyFormResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key-form.resolver.js';
|
||||
import { ApiKeyFormService } from '@app/unraid-api/graph/resolvers/api-key/api-key-form.service.js';
|
||||
import { ApiKeyPermissionsResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key-permissions.resolver.js';
|
||||
import { ApiKeyMutationsResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.mutation.js';
|
||||
import { ApiKeyResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.resolver.js';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
providers: [ApiKeyResolver, ApiKeyService, AuthService, ApiKeyMutationsResolver],
|
||||
exports: [ApiKeyResolver, ApiKeyService],
|
||||
providers: [
|
||||
ApiKeyResolver,
|
||||
ApiKeyService,
|
||||
AuthService,
|
||||
ApiKeyMutationsResolver,
|
||||
ApiKeyPermissionsResolver,
|
||||
ApiKeyFormService,
|
||||
ApiKeyFormResolver,
|
||||
],
|
||||
exports: [ApiKeyResolver, ApiKeyService, ApiKeyFormService],
|
||||
})
|
||||
export class ApiKeyModule {}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
|
||||
import {
|
||||
ApiKey,
|
||||
ApiKeyWithSecret,
|
||||
CreateApiKeyInput,
|
||||
DeleteApiKeyInput,
|
||||
} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
@@ -23,16 +22,7 @@ describe('ApiKeyMutationsResolver', () => {
|
||||
|
||||
const mockApiKey: ApiKey = {
|
||||
id: 'test-api-id',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
const mockApiKeyWithSecret: ApiKeyWithSecret = {
|
||||
id: 'test-api-id',
|
||||
key: 'test-api-key',
|
||||
key: 'test-secret-key',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
@@ -61,12 +51,12 @@ describe('ApiKeyMutationsResolver', () => {
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret);
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKey);
|
||||
vi.spyOn(authService, 'syncApiKeyRoles').mockResolvedValue();
|
||||
|
||||
const result = await resolver.create(input);
|
||||
|
||||
expect(result).toEqual(mockApiKeyWithSecret);
|
||||
expect(result).toEqual(mockApiKey);
|
||||
expect(apiKeyService.create).toHaveBeenCalledWith({
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
@@ -95,7 +85,7 @@ describe('ApiKeyMutationsResolver', () => {
|
||||
roles: [Role.GUEST],
|
||||
permissions: [],
|
||||
};
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret);
|
||||
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKey);
|
||||
vi.spyOn(authService, 'syncApiKeyRoles').mockRejectedValue(new Error('Sync failed'));
|
||||
await expect(resolver.create(input)).rejects.toThrow('Sync failed');
|
||||
});
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import {
|
||||
AddRoleForApiKeyInput,
|
||||
ApiKeyWithSecret,
|
||||
ApiKey,
|
||||
CreateApiKeyInput,
|
||||
DeleteApiKeyInput,
|
||||
RemoveRoleFromApiKeyInput,
|
||||
UpdateApiKeyInput,
|
||||
} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { ApiKeyMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js';
|
||||
import { validateObject } from '@app/unraid-api/graph/resolvers/validation.utils.js';
|
||||
|
||||
@Resolver(() => ApiKeyMutations)
|
||||
export class ApiKeyMutationsResolver {
|
||||
@@ -28,12 +23,11 @@ export class ApiKeyMutationsResolver {
|
||||
) {}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.CREATE,
|
||||
action: AuthAction.CREATE_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => ApiKeyWithSecret, { description: 'Create an API key' })
|
||||
async create(@Args('input') input: CreateApiKeyInput): Promise<ApiKeyWithSecret> {
|
||||
@ResolveField(() => ApiKey, { description: 'Create an API key' })
|
||||
async create(@Args('input') input: CreateApiKeyInput): Promise<ApiKey> {
|
||||
const apiKey = await this.apiKeyService.create({
|
||||
name: input.name,
|
||||
description: input.description ?? undefined,
|
||||
@@ -46,9 +40,8 @@ export class ApiKeyMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => Boolean, { description: 'Add a role to an API key' })
|
||||
async addRole(@Args('input') input: AddRoleForApiKeyInput): Promise<boolean> {
|
||||
@@ -56,9 +49,8 @@ export class ApiKeyMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => Boolean, { description: 'Remove a role from an API key' })
|
||||
async removeRole(@Args('input') input: RemoveRoleFromApiKeyInput): Promise<boolean> {
|
||||
@@ -66,9 +58,8 @@ export class ApiKeyMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.DELETE,
|
||||
action: AuthAction.DELETE_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => Boolean, { description: 'Delete one or more API keys' })
|
||||
async delete(@Args('input') input: DeleteApiKeyInput): Promise<boolean> {
|
||||
@@ -77,12 +68,11 @@ export class ApiKeyMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => ApiKeyWithSecret, { description: 'Update an API key' })
|
||||
async update(@Args('input') input: UpdateApiKeyInput): Promise<ApiKeyWithSecret> {
|
||||
@ResolveField(() => ApiKey, { description: 'Update an API key' })
|
||||
async update(@Args('input') input: UpdateApiKeyInput): Promise<ApiKey> {
|
||||
const apiKey = await this.apiKeyService.update(input);
|
||||
await this.authService.syncApiKeyRoles(apiKey.id, apiKey.roles);
|
||||
return apiKey;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
|
||||
import { ApiKey, ApiKeyWithSecret } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { ApiKey } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
import { ApiKeyResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.resolver.js';
|
||||
|
||||
describe('ApiKeyResolver', () => {
|
||||
@@ -18,16 +18,7 @@ describe('ApiKeyResolver', () => {
|
||||
|
||||
const mockApiKey: ApiKey = {
|
||||
id: 'test-api-id',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
createdAt: new Date().toISOString(),
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
const mockApiKeyWithSecret: ApiKeyWithSecret = {
|
||||
id: 'test-api-id',
|
||||
key: 'test-api-key',
|
||||
key: 'test-secret-key',
|
||||
name: 'Test API Key',
|
||||
description: 'Test API Key Description',
|
||||
roles: [Role.GUEST],
|
||||
@@ -44,7 +35,7 @@ describe('ApiKeyResolver', () => {
|
||||
authzService = new AuthZService(enforcer);
|
||||
cookieService = new CookieService();
|
||||
authService = new AuthService(cookieService, apiKeyService, authzService);
|
||||
resolver = new ApiKeyResolver(authService, apiKeyService);
|
||||
resolver = new ApiKeyResolver(apiKeyService);
|
||||
});
|
||||
|
||||
describe('apiKeys', () => {
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
import { Args, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
|
||||
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
|
||||
import { ApiKey, Permission } from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
|
||||
|
||||
@Resolver(() => ApiKey)
|
||||
export class ApiKeyResolver {
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private apiKeyService: ApiKeyService
|
||||
) {}
|
||||
constructor(private apiKeyService: ApiKeyService) {}
|
||||
|
||||
@Query(() => [ApiKey])
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async apiKeys(): Promise<ApiKey[]> {
|
||||
return this.apiKeyService.findAll();
|
||||
@@ -31,9 +22,8 @@ export class ApiKeyResolver {
|
||||
|
||||
@Query(() => ApiKey, { nullable: true })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.API_KEY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async apiKey(
|
||||
@Args('id', { type: () => PrefixedID })
|
||||
@@ -44,9 +34,8 @@ export class ApiKeyResolver {
|
||||
|
||||
@Query(() => [Role], { description: 'All possible roles for API keys' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.PERMISSION,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async apiKeyPossibleRoles(): Promise<Role[]> {
|
||||
return Object.values(Role);
|
||||
@@ -54,14 +43,13 @@ export class ApiKeyResolver {
|
||||
|
||||
@Query(() => [Permission], { description: 'All possible permissions for API keys' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.PERMISSION,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async apiKeyPossiblePermissions(): Promise<Permission[]> {
|
||||
// Build all combinations of Resource and AuthActionVerb
|
||||
// Build all combinations of Resource and AuthAction
|
||||
const resources = Object.values(Resource);
|
||||
const actions = Object.values(AuthActionVerb);
|
||||
const actions = Object.values(AuthAction);
|
||||
return resources.map((resource) => ({
|
||||
resource,
|
||||
actions,
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Args, Mutation, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import {
|
||||
ArrayDisk,
|
||||
@@ -27,9 +23,8 @@ export class ArrayMutationsResolver {
|
||||
|
||||
@ResolveField(() => UnraidArray, { description: 'Set array state' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async setState(@Args('input') input: ArrayStateInput): Promise<UnraidArray> {
|
||||
return this.arrayService.updateArrayState(input);
|
||||
@@ -37,9 +32,8 @@ export class ArrayMutationsResolver {
|
||||
|
||||
@ResolveField(() => UnraidArray, { description: 'Add new disk to array' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async addDiskToArray(@Args('input') input: ArrayDiskInput): Promise<UnraidArray> {
|
||||
return this.arrayService.addDiskToArray(input);
|
||||
@@ -50,9 +44,8 @@ export class ArrayMutationsResolver {
|
||||
"Remove existing disk from array. NOTE: The array must be stopped before running this otherwise it'll throw an error.",
|
||||
})
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async removeDiskFromArray(@Args('input') input: ArrayDiskInput): Promise<UnraidArray> {
|
||||
return this.arrayService.removeDiskFromArray(input);
|
||||
@@ -60,9 +53,8 @@ export class ArrayMutationsResolver {
|
||||
|
||||
@ResolveField(() => ArrayDisk, { description: 'Mount a disk in the array' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async mountArrayDisk(@Args('id', { type: () => PrefixedID }) id: string): Promise<ArrayDisk> {
|
||||
const array = await this.arrayService.mountArrayDisk(id);
|
||||
@@ -80,9 +72,8 @@ export class ArrayMutationsResolver {
|
||||
|
||||
@ResolveField(() => ArrayDisk, { description: 'Unmount a disk from the array' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async unmountArrayDisk(
|
||||
@Args('id', { type: () => PrefixedID }) id: string
|
||||
@@ -102,9 +93,8 @@ export class ArrayMutationsResolver {
|
||||
|
||||
@ResolveField(() => Boolean, { description: 'Clear statistics for a disk in the array' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async clearArrayDiskStatistics(
|
||||
@Args('id', { type: () => PrefixedID }) id: string
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { UnraidArray } from '@app/unraid-api/graph/resolvers/array/array.model.js';
|
||||
@@ -17,9 +13,8 @@ export class ArrayResolver {
|
||||
|
||||
@Query(() => UnraidArray)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async array() {
|
||||
return this.arrayService.getArrayData();
|
||||
@@ -27,9 +22,8 @@ export class ArrayResolver {
|
||||
|
||||
@Subscription(() => UnraidArray)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async arraySubscription() {
|
||||
return createSubscription(PUBSUB_CHANNEL.ARRAY);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Args, Mutation, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
import { GraphQLJSON } from 'graphql-scalars';
|
||||
|
||||
import { ParityService } from '@app/unraid-api/graph/resolvers/array/parity.service.js';
|
||||
@@ -19,9 +15,8 @@ export class ParityCheckMutationsResolver {
|
||||
constructor(private readonly parityService: ParityService) {}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => GraphQLJSON, { description: 'Start a parity check' })
|
||||
async start(@Args('correct') correct: boolean): Promise<object> {
|
||||
@@ -32,9 +27,8 @@ export class ParityCheckMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => GraphQLJSON, { description: 'Pause a parity check' })
|
||||
async pause(): Promise<object> {
|
||||
@@ -45,9 +39,8 @@ export class ParityCheckMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => GraphQLJSON, { description: 'Resume a parity check' })
|
||||
async resume(): Promise<object> {
|
||||
@@ -58,9 +51,8 @@ export class ParityCheckMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => GraphQLJSON, { description: 'Cancel a parity check' })
|
||||
async cancel(): Promise<object> {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
import { PubSub } from 'graphql-subscriptions';
|
||||
|
||||
import { PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
@@ -23,9 +19,8 @@ export class ParityResolver {
|
||||
) {}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Query(() => [ParityCheck])
|
||||
async parityHistory(): Promise<ParityCheck[]> {
|
||||
@@ -33,9 +28,8 @@ export class ParityResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.ARRAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Subscription(() => ParityCheck)
|
||||
parityHistorySubscription() {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { Config } from '@app/unraid-api/graph/resolvers/config/config.model.js';
|
||||
@@ -14,9 +10,8 @@ import { Config } from '@app/unraid-api/graph/resolvers/config/config.model.js';
|
||||
export class ConfigResolver {
|
||||
@Query(() => Config)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.CONFIG,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async config(): Promise<Config> {
|
||||
const emhttp = getters.emhttp();
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { Public } from '@app/unraid-api/auth/public.decorator.js'; // Import Public decorator
|
||||
|
||||
@@ -23,9 +19,8 @@ export class CustomizationResolver {
|
||||
// Authenticated query
|
||||
@Query(() => Customization, { nullable: true })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.CUSTOMIZATIONS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async customization(): Promise<Customization | null> {
|
||||
// We return an empty object because the fields are resolved by @ResolveField
|
||||
@@ -52,9 +47,8 @@ export class CustomizationResolver {
|
||||
|
||||
@ResolveField(() => ActivationCode, { nullable: true, name: 'activationCode' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.ACTIVATION_CODE,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async activationCode(): Promise<ActivationCode | null> {
|
||||
return this.customizationService.getActivationData();
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { Args, Int, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { Disk } from '@app/unraid-api/graph/resolvers/disks/disks.model.js';
|
||||
import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js';
|
||||
@@ -17,9 +13,8 @@ export class DisksResolver {
|
||||
|
||||
@Query(() => [Disk])
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DISK,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async disks() {
|
||||
return this.disksService.getDisks();
|
||||
@@ -27,9 +22,8 @@ export class DisksResolver {
|
||||
|
||||
@Query(() => Disk)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DISK,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async disk(@Args('id', { type: () => PrefixedID }) id: string) {
|
||||
return this.disksService.getDisk(id);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { Display } from '@app/unraid-api/graph/resolvers/info/display/display.model.js';
|
||||
@@ -16,9 +12,8 @@ export class DisplayResolver {
|
||||
constructor(private readonly displayService: DisplayService) {}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DISPLAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Query(() => Display)
|
||||
public async display(): Promise<Display> {
|
||||
@@ -27,9 +22,8 @@ export class DisplayResolver {
|
||||
|
||||
@Subscription(() => Display)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DISPLAY,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async displaySubscription() {
|
||||
return createSubscription(PUBSUB_CHANNEL.DISPLAY);
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
|
||||
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
|
||||
@@ -21,9 +17,8 @@ export class DockerMutationsResolver {
|
||||
|
||||
@ResolveField(() => DockerContainer, { description: 'Start a container' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async start(@Args('id', { type: () => PrefixedID }) id: string) {
|
||||
return this.dockerService.start(id);
|
||||
@@ -31,9 +26,8 @@ export class DockerMutationsResolver {
|
||||
|
||||
@ResolveField(() => DockerContainer, { description: 'Stop a container' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async stop(@Args('id', { type: () => PrefixedID }) id: string) {
|
||||
return this.dockerService.stop(id);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/docker-organizer.service.js';
|
||||
import {
|
||||
@@ -25,9 +21,8 @@ export class DockerResolver {
|
||||
) {}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Query(() => Docker)
|
||||
public docker() {
|
||||
@@ -37,9 +32,8 @@ export class DockerResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => [DockerContainer])
|
||||
public async containers(
|
||||
@@ -49,9 +43,8 @@ export class DockerResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => [DockerNetwork])
|
||||
public async networks(
|
||||
@@ -61,9 +54,8 @@ export class DockerResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => ResolvedOrganizerV1)
|
||||
public async organizer() {
|
||||
@@ -71,9 +63,8 @@ export class DockerResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Mutation(() => ResolvedOrganizerV1)
|
||||
public async createDockerFolder(
|
||||
@@ -90,9 +81,8 @@ export class DockerResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Mutation(() => ResolvedOrganizerV1)
|
||||
public async setDockerFolderChildren(
|
||||
@@ -107,9 +97,8 @@ export class DockerResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Mutation(() => ResolvedOrganizerV1)
|
||||
public async deleteDockerEntries(@Args('entryIds', { type: () => [String] }) entryIds: string[]) {
|
||||
@@ -120,9 +109,8 @@ export class DockerResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.DOCKER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@Mutation(() => ResolvedOrganizerV1)
|
||||
public async moveDockerEntriesToFolder(
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { Flash } from '@app/unraid-api/graph/resolvers/flash/flash.model.js';
|
||||
@@ -14,9 +10,8 @@ import { Flash } from '@app/unraid-api/graph/resolvers/flash/flash.model.js';
|
||||
export class FlashResolver {
|
||||
@Query(() => Flash)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.FLASH,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async flash() {
|
||||
const emhttp = getters.emhttp();
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { GraphQLISODateTime, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
import { baseboard as getBaseboard, system as getSystem } from 'systeminformation';
|
||||
|
||||
import { getMachineId } from '@app/core/utils/misc/get-machine-id.js';
|
||||
@@ -35,9 +31,8 @@ export class InfoResolver {
|
||||
|
||||
@Query(() => Info)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.INFO,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async info(): Promise<Partial<Info>> {
|
||||
return {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Args, Int, Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { LogFile, LogFileContent } from '@app/unraid-api/graph/resolvers/logs/logs.model.js';
|
||||
@@ -17,9 +13,8 @@ export class LogsResolver {
|
||||
|
||||
@Query(() => [LogFile])
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.LOGS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async logFiles(): Promise<LogFile[]> {
|
||||
return this.logsService.listLogFiles();
|
||||
@@ -27,9 +22,8 @@ export class LogsResolver {
|
||||
|
||||
@Query(() => LogFileContent)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.LOGS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async logFile(
|
||||
@Args('path') path: string,
|
||||
@@ -41,9 +35,8 @@ export class LogsResolver {
|
||||
|
||||
@Subscription(() => LogFileContent, { name: 'logFile' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.LOGS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async logFileSubscription(@Args('path') path: string) {
|
||||
// Start watching the file
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { OnModuleInit } from '@nestjs/common';
|
||||
import { Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { CpuUtilization } from '@app/unraid-api/graph/resolvers/info/cpu/cpu.model.js';
|
||||
@@ -50,9 +46,8 @@ export class MetricsResolver implements OnModuleInit {
|
||||
|
||||
@Query(() => Metrics)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.INFO,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async metrics(): Promise<Partial<Metrics>> {
|
||||
return {
|
||||
@@ -75,9 +70,8 @@ export class MetricsResolver implements OnModuleInit {
|
||||
resolve: (value) => value.systemMetricsCpu,
|
||||
})
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.INFO,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async systemMetricsCpuSubscription() {
|
||||
return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.CPU_UTILIZATION);
|
||||
@@ -88,9 +82,8 @@ export class MetricsResolver implements OnModuleInit {
|
||||
resolve: (value) => value.systemMetricsMemory,
|
||||
})
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.INFO,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async systemMetricsMemorySubscription() {
|
||||
return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.MEMORY_UTILIZATION);
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { Args, Mutation, Query, ResolveField, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { AppError } from '@app/core/errors/app-error.js';
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
@@ -31,9 +27,8 @@ export class NotificationsResolver {
|
||||
|
||||
@Query(() => Notifications, { description: 'Get all notifications' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.NOTIFICATIONS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async notifications(): Promise<Notifications> {
|
||||
return {
|
||||
@@ -153,9 +148,8 @@ export class NotificationsResolver {
|
||||
|
||||
@Subscription(() => Notification)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.NOTIFICATIONS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async notificationAdded() {
|
||||
return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_ADDED);
|
||||
@@ -163,9 +157,8 @@ export class NotificationsResolver {
|
||||
|
||||
@Subscription(() => NotificationOverview)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.NOTIFICATIONS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async notificationsOverview() {
|
||||
return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { Online } from '@app/unraid-api/graph/resolvers/online/online.model.js';
|
||||
|
||||
@@ -13,9 +9,8 @@ import { Online } from '@app/unraid-api/graph/resolvers/online/online.model.js';
|
||||
export class OnlineResolver {
|
||||
@Query(() => Boolean)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.ONLINE,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async online() {
|
||||
return true;
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { Owner } from '@app/unraid-api/graph/resolvers/owner/owner.model.js';
|
||||
@@ -17,9 +13,8 @@ export class OwnerResolver {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
@Query(() => Owner)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.OWNER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async owner() {
|
||||
const config = this.configService.get('connect.config');
|
||||
@@ -40,9 +35,8 @@ export class OwnerResolver {
|
||||
|
||||
@Subscription(() => Owner)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.OWNER,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public ownerSubscription() {
|
||||
return createSubscription(PUBSUB_CHANNEL.OWNER);
|
||||
|
||||
@@ -255,6 +255,7 @@ export function getProviderConfigSlice({
|
||||
description: option.Help || undefined,
|
||||
controlOptions: controlOptions,
|
||||
rule: providerRule,
|
||||
passScopeToLayout: true,
|
||||
});
|
||||
|
||||
return labeledControl;
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { RCloneMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js';
|
||||
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
|
||||
@@ -27,9 +23,8 @@ export class RCloneMutationsResolver {
|
||||
|
||||
@ResolveField(() => RCloneRemote, { description: 'Create a new RClone remote' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.CREATE,
|
||||
action: AuthAction.CREATE_ANY,
|
||||
resource: Resource.FLASH,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async createRCloneRemote(@Args('input') input: CreateRCloneRemoteInput): Promise<RCloneRemote> {
|
||||
try {
|
||||
@@ -48,9 +43,8 @@ export class RCloneMutationsResolver {
|
||||
|
||||
@ResolveField(() => Boolean, { description: 'Delete an existing RClone remote' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.DELETE,
|
||||
action: AuthAction.DELETE_ANY,
|
||||
resource: Resource.FLASH,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async deleteRCloneRemote(@Args('input') input: DeleteRCloneRemoteInput): Promise<boolean> {
|
||||
try {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Args, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { RCloneApiService } from '@app/unraid-api/graph/resolvers/rclone/rclone-api.service.js';
|
||||
import { RCloneFormService } from '@app/unraid-api/graph/resolvers/rclone/rclone-form.service.js';
|
||||
@@ -31,9 +27,8 @@ export class RCloneBackupSettingsResolver {
|
||||
|
||||
@Query(() => RCloneBackupSettings)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.FLASH,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async rclone(): Promise<RCloneBackupSettings> {
|
||||
return {} as RCloneBackupSettings;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { getKeyFile } from '@app/core/utils/misc/get-key-file.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
@@ -19,9 +15,8 @@ import {
|
||||
export class RegistrationResolver {
|
||||
@Query(() => Registration, { nullable: true })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.REGISTRATION,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async registration(): Promise<Registration | null> {
|
||||
const emhttp = getters.emhttp();
|
||||
|
||||
@@ -2,12 +2,8 @@ import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
@@ -24,9 +20,8 @@ export class ServerResolver {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
@Query(() => ServerModel, { nullable: true })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.SERVERS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async server(): Promise<ServerModel | null> {
|
||||
return this.getLocalServer()[0] || null;
|
||||
@@ -34,9 +29,8 @@ export class ServerResolver {
|
||||
|
||||
@Query(() => [ServerModel])
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.SERVERS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async servers(): Promise<ServerModel[]> {
|
||||
return this.getLocalServer();
|
||||
@@ -44,9 +38,8 @@ export class ServerResolver {
|
||||
|
||||
@Subscription(() => ServerModel)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.SERVERS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async serversSubscription() {
|
||||
return createSubscription(PUBSUB_CHANNEL.SERVERS);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
import { Field, InterfaceType, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { Node } from '@unraid/shared/graphql.model.js';
|
||||
import { IsObject, ValidateNested } from 'class-validator';
|
||||
@@ -6,10 +6,25 @@ import { GraphQLJSON } from 'graphql-scalars';
|
||||
|
||||
import { SsoSettings } from '@app/unraid-api/graph/resolvers/settings/sso-settings.model.js';
|
||||
|
||||
@InterfaceType()
|
||||
export abstract class FormSchema {
|
||||
@Field(() => GraphQLJSON, { description: 'The data schema for the form' })
|
||||
@IsObject()
|
||||
dataSchema!: Record<string, any>;
|
||||
|
||||
@Field(() => GraphQLJSON, { description: 'The UI schema for the form' })
|
||||
@IsObject()
|
||||
uiSchema!: Record<string, any>;
|
||||
|
||||
@Field(() => GraphQLJSON, { description: 'The current values of the form' })
|
||||
@IsObject()
|
||||
values!: Record<string, any>;
|
||||
}
|
||||
|
||||
@ObjectType({
|
||||
implements: () => Node,
|
||||
implements: () => [Node, FormSchema],
|
||||
})
|
||||
export class UnifiedSettings extends Node {
|
||||
export class UnifiedSettings extends Node implements FormSchema {
|
||||
@Field(() => GraphQLJSON, { description: 'The data schema for the settings' })
|
||||
@IsObject()
|
||||
dataSchema!: Record<string, any>;
|
||||
@@ -23,6 +38,23 @@ export class UnifiedSettings extends Node {
|
||||
values!: Record<string, any>;
|
||||
}
|
||||
|
||||
@ObjectType({
|
||||
implements: () => [Node, FormSchema],
|
||||
})
|
||||
export class ApiKeyFormSettings extends Node implements FormSchema {
|
||||
@Field(() => GraphQLJSON, { description: 'The data schema for the API key form' })
|
||||
@IsObject()
|
||||
dataSchema!: Record<string, any>;
|
||||
|
||||
@Field(() => GraphQLJSON, { description: 'The UI schema for the API key form' })
|
||||
@IsObject()
|
||||
uiSchema!: Record<string, any>;
|
||||
|
||||
@Field(() => GraphQLJSON, { description: 'The current values of the API key form' })
|
||||
@IsObject()
|
||||
values!: Record<string, any>;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class UpdateSettingsResponse {
|
||||
@Field(() => Boolean, {
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { ApiConfig } from '@unraid/shared/services/api-config.js';
|
||||
import { UserSettingsService } from '@unraid/shared/services/user-settings.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
import { GraphQLJSON } from 'graphql-scalars';
|
||||
|
||||
import { LifecycleService } from '@app/unraid-api/app/lifecycle.service.js';
|
||||
@@ -101,9 +97,8 @@ export class UnifiedSettingsResolver {
|
||||
|
||||
@Mutation(() => UpdateSettingsResponse)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.CONFIG,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async updateSettings(
|
||||
@Args('input', { type: () => GraphQLJSON }) input: Record<string, unknown>
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Args, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { Public } from '@app/unraid-api/auth/public.decorator.js';
|
||||
import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/oidc-config.service.js';
|
||||
@@ -73,9 +70,8 @@ export class SsoResolver {
|
||||
|
||||
@Query(() => [OidcProvider], { description: 'Get all configured OIDC providers (admin only)' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: 'sso',
|
||||
possession: AuthPossession.ANY,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.CONFIG,
|
||||
})
|
||||
public async oidcProviders(): Promise<OidcProvider[]> {
|
||||
return this.oidcConfig.getProviders();
|
||||
@@ -83,9 +79,8 @@ export class SsoResolver {
|
||||
|
||||
@Query(() => OidcProvider, { nullable: true, description: 'Get a specific OIDC provider by ID' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: 'sso',
|
||||
possession: AuthPossession.ANY,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.CONFIG,
|
||||
})
|
||||
public async oidcProvider(
|
||||
@Args('id', { type: () => PrefixedID }) id: string
|
||||
@@ -97,9 +92,8 @@ export class SsoResolver {
|
||||
description: 'Validate an OIDC session token (internal use for CLI validation)',
|
||||
})
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: 'sso',
|
||||
possession: AuthPossession.ANY,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.CONFIG,
|
||||
})
|
||||
public async validateOidcSession(@Args('token') token: string): Promise<OidcSessionValidation> {
|
||||
return await this.oidcSessionService.validateSession(token);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { getters } from '@app/store/index.js';
|
||||
import { Public } from '@app/unraid-api/auth/public.decorator.js';
|
||||
@@ -16,9 +12,8 @@ import { Vars } from '@app/unraid-api/graph/resolvers/vars/vars.model.js';
|
||||
export class VarsResolver {
|
||||
@Query(() => Vars)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.VARS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async vars() {
|
||||
return {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { VmMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js';
|
||||
import { VmsService } from '@app/unraid-api/graph/resolvers/vms/vms.service.js';
|
||||
@@ -19,9 +15,8 @@ export class VmMutationsResolver {
|
||||
constructor(private readonly vmsService: VmsService) {}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.VMS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => Boolean, { description: 'Start a virtual machine' })
|
||||
async start(@Args('id', { type: () => PrefixedID }) id: string): Promise<boolean> {
|
||||
@@ -29,9 +24,8 @@ export class VmMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.VMS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => Boolean, { description: 'Stop a virtual machine' })
|
||||
async stop(@Args('id', { type: () => PrefixedID }) id: string): Promise<boolean> {
|
||||
@@ -39,9 +33,8 @@ export class VmMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.VMS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => Boolean, { description: 'Pause a virtual machine' })
|
||||
async pause(@Args('id', { type: () => PrefixedID }) id: string): Promise<boolean> {
|
||||
@@ -49,9 +42,8 @@ export class VmMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.VMS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => Boolean, { description: 'Resume a virtual machine' })
|
||||
async resume(@Args('id', { type: () => PrefixedID }) id: string): Promise<boolean> {
|
||||
@@ -59,9 +51,8 @@ export class VmMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.VMS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => Boolean, { description: 'Force stop a virtual machine' })
|
||||
async forceStop(@Args('id', { type: () => PrefixedID }) id: string): Promise<boolean> {
|
||||
@@ -69,9 +60,8 @@ export class VmMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.VMS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => Boolean, { description: 'Reboot a virtual machine' })
|
||||
async reboot(@Args('id', { type: () => PrefixedID }) id: string): Promise<boolean> {
|
||||
@@ -79,9 +69,8 @@ export class VmMutationsResolver {
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.VMS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
@ResolveField(() => Boolean, { description: 'Reset a virtual machine' })
|
||||
async reset(@Args('id', { type: () => PrefixedID }) id: string): Promise<boolean> {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { VmDomain, Vms } from '@app/unraid-api/graph/resolvers/vms/vms.model.js';
|
||||
import { VmsService } from '@app/unraid-api/graph/resolvers/vms/vms.service.js';
|
||||
@@ -16,9 +12,8 @@ export class VmsResolver {
|
||||
|
||||
@Query(() => Vms, { description: 'Get information about all VMs on the system' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.VMS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async vms() {
|
||||
return {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { bootTimestamp } from '@app/common/dashboard/boot-timestamp.js';
|
||||
import { API_VERSION } from '@app/environment.js';
|
||||
@@ -48,9 +44,8 @@ export class ServicesResolver {
|
||||
|
||||
@Query(() => [Service])
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.SERVICES,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public services(): Service[] {
|
||||
const dynamicRemoteAccess = this.getDynamicRemoteAccessService();
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { getShares } from '@app/core/utils/shares/get-shares.js';
|
||||
import { Share } from '@app/unraid-api/graph/resolvers/array/array.model.js';
|
||||
@@ -16,9 +12,8 @@ export class SharesResolver {
|
||||
|
||||
@Query(() => [Share])
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.SHARE,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async shares() {
|
||||
const userShares = getShares('users');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthZService } from 'nest-authz';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('MeResolver', () => {
|
||||
permissions: [
|
||||
{
|
||||
resource: Resource.ME,
|
||||
actions: ['read'],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { GraphqlUser } from '@app/unraid-api/auth/user.decorator.js';
|
||||
import { UserAccount } from '@app/unraid-api/graph/user/user.model.js';
|
||||
@@ -16,9 +12,8 @@ export class MeResolver {
|
||||
|
||||
@Query(() => UserAccount)
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.ME,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
public async me(@GraphqlUser() user: UserAccount): Promise<UserAccount> {
|
||||
return user;
|
||||
|
||||
@@ -53,34 +53,44 @@ export function createLabeledControl({
|
||||
controlOptions,
|
||||
labelOptions,
|
||||
layoutOptions,
|
||||
layoutType = 'UnraidSettingsLayout',
|
||||
rule,
|
||||
passScopeToLayout = false,
|
||||
}: {
|
||||
scope: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
controlOptions: ControlElement['options'];
|
||||
controlOptions?: ControlElement['options'];
|
||||
labelOptions?: LabelElement['options'];
|
||||
layoutOptions?: Layout['options'];
|
||||
layoutType?: 'UnraidSettingsLayout' | 'VerticalLayout' | 'HorizontalLayout';
|
||||
rule?: Rule;
|
||||
passScopeToLayout?: boolean;
|
||||
}): Layout {
|
||||
const layout: Layout & { scope?: string } = {
|
||||
type: 'UnraidSettingsLayout', // Use the specific Unraid layout type
|
||||
scope: scope, // Apply scope to the layout for potential rules/visibility
|
||||
const elements: Array<LabelElement | ControlElement> = [
|
||||
{
|
||||
type: 'Label',
|
||||
text: label,
|
||||
options: { ...labelOptions, description },
|
||||
} as LabelElement,
|
||||
{
|
||||
type: 'Control',
|
||||
scope: scope,
|
||||
options: controlOptions,
|
||||
} as ControlElement,
|
||||
];
|
||||
|
||||
const layout: Layout = {
|
||||
type: layoutType,
|
||||
options: layoutOptions,
|
||||
elements: [
|
||||
{
|
||||
type: 'Label',
|
||||
text: label,
|
||||
scope: scope, // Scope might be needed for specific label behaviors
|
||||
options: { ...labelOptions, description },
|
||||
} as LabelElement,
|
||||
{
|
||||
type: 'Control',
|
||||
scope: scope,
|
||||
options: controlOptions,
|
||||
} as ControlElement,
|
||||
],
|
||||
};
|
||||
elements,
|
||||
} as Layout;
|
||||
|
||||
// Optionally add scope to the layout itself (for backward compatibility)
|
||||
if (passScopeToLayout) {
|
||||
(layout as any).scope = scope;
|
||||
}
|
||||
|
||||
// Conditionally add the rule to the layout if provided
|
||||
if (rule) {
|
||||
layout.rule = rule;
|
||||
|
||||
@@ -2,11 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import {
|
||||
AuthActionVerb,
|
||||
AuthPossession,
|
||||
UsePermissions,
|
||||
} from '@unraid/shared/use-permissions.directive.js';
|
||||
import { AuthAction, UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import { LifecycleService } from '@app/unraid-api/app/lifecycle.service.js';
|
||||
import { PluginManagementService } from '@app/unraid-api/plugin/plugin-management.service.js';
|
||||
@@ -23,9 +19,8 @@ export class PluginResolver {
|
||||
|
||||
@Query(() => [Plugin], { description: 'List all installed plugins with their metadata' })
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.CONFIG,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async plugins(): Promise<Plugin[]> {
|
||||
const plugins = await PluginService.getPlugins();
|
||||
@@ -47,9 +42,8 @@ export class PluginResolver {
|
||||
'Add one or more plugins to the API. Returns false if restart was triggered automatically, true if manual restart is required.',
|
||||
})
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.UPDATE,
|
||||
action: AuthAction.UPDATE_ANY,
|
||||
resource: Resource.CONFIG,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async addPlugin(@Args('input') input: PluginManagementInput): Promise<boolean> {
|
||||
if (input.bundled) {
|
||||
@@ -76,9 +70,8 @@ export class PluginResolver {
|
||||
'Remove one or more plugins from the API. Returns false if restart was triggered automatically, true if manual restart is required.',
|
||||
})
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.DELETE,
|
||||
action: AuthAction.DELETE_ANY,
|
||||
resource: Resource.CONFIG,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async removePlugin(@Args('input') input: PluginManagementInput): Promise<boolean> {
|
||||
if (input.bundled) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Controller, Get, Logger, Param, Query, Req, Res, UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
import { Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
import type { FastifyReply, FastifyRequest } from '@app/unraid-api/types/fastify.js';
|
||||
import { Public } from '@app/unraid-api/auth/public.decorator.js';
|
||||
@@ -24,9 +24,8 @@ export class RestController {
|
||||
|
||||
@Get('/graphql/api/logs')
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.LOGS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async getLogs(@Res() res: FastifyReply) {
|
||||
try {
|
||||
@@ -40,9 +39,8 @@ export class RestController {
|
||||
|
||||
@Get('/graphql/api/customizations/:type')
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
action: AuthAction.READ_ANY,
|
||||
resource: Resource.CUSTOMIZATIONS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async getCustomizations(@Param('type') type: string, @Res() res: FastifyReply) {
|
||||
if (type !== 'banner' && type !== 'case') {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
Menu="WebGui"
|
||||
Title="API Key Authorization"
|
||||
Icon="icon-u-shield-keyhole"
|
||||
Tag="key"
|
||||
Cond="false"
|
||||
---
|
||||
<unraid-api-key-authorize />
|
||||
Generated
+4
-4
@@ -13383,8 +13383,8 @@ packages:
|
||||
vue-component-type-helpers@3.0.4:
|
||||
resolution: {integrity: sha512-WtR3kPk8vqKYfCK/HGyT47lK/T3FaVyWxaCNuosaHYE8h9/k0lYRZ/PI/+T/z2wP+uuNKmL6z30rOcBboOu/YA==}
|
||||
|
||||
vue-component-type-helpers@3.0.5:
|
||||
resolution: {integrity: sha512-uoNZaJ+a1/zppa/Vgmi8zIOP2PHXDN2rT8NyF+zQRK6ZG94lNB9prcV0GdLJbY9i9lrD47JOVIH92SaiA7oJ1A==}
|
||||
vue-component-type-helpers@3.0.6:
|
||||
resolution: {integrity: sha512-6CRM8X7EJqWCJOiKPvSLQG+hJPb/Oy2gyJx3pLjUEhY7PuaCthQu3e0zAGI1lqUBobrrk9IT0K8sG2GsCluxoQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -17892,7 +17892,7 @@ snapshots:
|
||||
storybook: 9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.1(@types/node@22.17.1)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1))
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.18(typescript@5.9.2)
|
||||
vue-component-type-helpers: 3.0.5
|
||||
vue-component-type-helpers: 3.0.6
|
||||
|
||||
'@stylistic/eslint-plugin@5.2.2(eslint@9.33.0(jiti@2.5.1))':
|
||||
dependencies:
|
||||
@@ -27724,7 +27724,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.0.4: {}
|
||||
|
||||
vue-component-type-helpers@3.0.5: {}
|
||||
vue-component-type-helpers@3.0.6: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.18(typescript@5.9.2)):
|
||||
dependencies:
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { toast as sonnerToast } from 'vue-sonner';
|
||||
|
||||
/**
|
||||
* Composable for toast notifications using vue-sonner
|
||||
* Provides a consistent API for showing toast messages
|
||||
*/
|
||||
export function useToast() {
|
||||
/**
|
||||
* Show a default toast notification
|
||||
* @param message - The message to display
|
||||
*/
|
||||
const toast = (message: string) => {
|
||||
sonnerToast(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Show a success toast
|
||||
* @param message - The success message to display
|
||||
*/
|
||||
const success = (message: string) => {
|
||||
sonnerToast.success(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Show an error toast
|
||||
* @param message - The error message to display
|
||||
*/
|
||||
const error = (message: string) => {
|
||||
sonnerToast.error(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Show a warning toast
|
||||
* @param message - The warning message to display
|
||||
*/
|
||||
const warning = (message: string) => {
|
||||
sonnerToast.warning(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Show an info toast
|
||||
* @param message - The info message to display
|
||||
*/
|
||||
const info = (message: string) => {
|
||||
sonnerToast.info(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Show a loading toast
|
||||
* @param message - The loading message to display
|
||||
*/
|
||||
const loading = (message: string) => {
|
||||
sonnerToast.loading(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Show a promise-based toast that updates based on promise state
|
||||
* @param promise - The promise to track
|
||||
* @param messages - Messages for different states
|
||||
* @returns The toast ID with an unwrap method to get the original promise
|
||||
*/
|
||||
const promise = <T>(
|
||||
promiseToHandle: Promise<T>,
|
||||
messages: {
|
||||
loading: string;
|
||||
success: string | ((data: T) => string);
|
||||
error: string | ((error: unknown) => string);
|
||||
}
|
||||
) => {
|
||||
// Return vue-sonner's promise return type which includes the toast ID and unwrap method
|
||||
return sonnerToast.promise(promiseToHandle, messages);
|
||||
};
|
||||
|
||||
/**
|
||||
* Dismiss a specific toast or all toasts
|
||||
* @param toastId - Optional toast ID to dismiss. If not provided, dismisses all toasts
|
||||
*/
|
||||
const dismiss = (toastId?: string | number) => {
|
||||
sonnerToast.dismiss(toastId);
|
||||
};
|
||||
|
||||
return {
|
||||
// Allow calling the composable directly as toast()
|
||||
toast,
|
||||
// Named methods
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
loading,
|
||||
promise,
|
||||
dismiss,
|
||||
// Also expose the raw sonner toast for advanced usage
|
||||
sonner: sonnerToast,
|
||||
};
|
||||
}
|
||||
|
||||
// Export type for the return value
|
||||
export type ToastInstance = ReturnType<typeof useToast>;
|
||||
@@ -51,8 +51,12 @@ const labelClass = computed(() => {
|
||||
switch (labelFormat.value) {
|
||||
case 'title':
|
||||
return 'text-xl font-semibold mb-2'; // Example styling for title
|
||||
case 'subtitle':
|
||||
return 'text-base font-semibold mb-1'; // Styling for subtitle
|
||||
case 'heading':
|
||||
return 'text-lg font-semibold mt-4 mb-1'; // Example styling for heading
|
||||
case 'description':
|
||||
return 'text-sm text-muted-foreground'; // Description format should not be bold
|
||||
default:
|
||||
return 'font-semibold'; // Default label styling
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
<script setup lang="ts">
|
||||
import { Badge } from '@/components/common/badge';
|
||||
import { Button } from '@/components/common/button';
|
||||
import {
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ChevronDownIcon, XMarkIcon } from '@heroicons/vue/24/outline';
|
||||
import type { ControlElement } from '@jsonforms/core';
|
||||
import { useJsonFormsControl } from '@jsonforms/vue';
|
||||
import type { RendererProps } from '@jsonforms/vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const props = defineProps<RendererProps<ControlElement>>();
|
||||
const { control, handleChange } = useJsonFormsControl(props);
|
||||
|
||||
// Handle array data
|
||||
const selectedValues = computed({
|
||||
get: () => {
|
||||
const data = control.value.data ?? [];
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
set: (values: string[]) => {
|
||||
handleChange(control.value.path, values);
|
||||
},
|
||||
});
|
||||
|
||||
// Get available options from schema
|
||||
const options = computed(() => {
|
||||
const schema = control.value.schema;
|
||||
let enumValues: string[] = [];
|
||||
|
||||
// Check for enum in schema.items (for array types)
|
||||
if (
|
||||
schema.type === 'array' &&
|
||||
typeof schema.items === 'object' &&
|
||||
!Array.isArray(schema.items) &&
|
||||
'enum' in schema.items
|
||||
) {
|
||||
enumValues = schema.items.enum as string[];
|
||||
}
|
||||
// Fallback to direct enum
|
||||
else if ('enum' in schema) {
|
||||
enumValues = schema.enum as string[];
|
||||
}
|
||||
|
||||
const labels: Record<string, string> | undefined = control.value.uischema.options?.labels;
|
||||
const descriptions: Record<string, string> | undefined = control.value.uischema.options?.descriptions;
|
||||
|
||||
return enumValues.map((value) => ({
|
||||
value,
|
||||
label: labels?.[value] || value,
|
||||
description: descriptions?.[value],
|
||||
}));
|
||||
});
|
||||
|
||||
// Display format from options
|
||||
const displayFormat = computed(() => control.value.uischema.options?.format || 'dropdown');
|
||||
|
||||
// Check if readonly
|
||||
const isReadonly = computed(() => control.value.uischema.options?.readonly || !control.value.enabled);
|
||||
|
||||
// Remove a specific value (for chips display)
|
||||
const removeValue = (value: string) => {
|
||||
if (isReadonly.value) return;
|
||||
selectedValues.value = selectedValues.value.filter((v) => v !== value);
|
||||
};
|
||||
|
||||
// Clear all selections
|
||||
const clearAll = () => {
|
||||
if (isReadonly.value) return;
|
||||
selectedValues.value = [];
|
||||
};
|
||||
|
||||
const dropdownOpen = ref(false);
|
||||
|
||||
// Placeholder text
|
||||
const placeholder = computed(() => {
|
||||
const customPlaceholder = control.value.uischema.options?.placeholder;
|
||||
if (customPlaceholder) return customPlaceholder;
|
||||
return options.value.length > 0 ? 'Select items...' : 'No items available';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Chips display format -->
|
||||
<div v-if="displayFormat === 'chips' || displayFormat === 'array'" class="space-y-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
v-for="value in selectedValues"
|
||||
:key="value"
|
||||
:variant="isReadonly ? 'gray' : 'blue'"
|
||||
size="sm"
|
||||
>
|
||||
<span>{{ options.find((o) => o.value === value)?.label || value }}</span>
|
||||
<button
|
||||
v-if="!isReadonly"
|
||||
@click.stop="removeValue(value)"
|
||||
class="ml-1 hover:text-destructive"
|
||||
type="button"
|
||||
>
|
||||
<XMarkIcon class="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
<span v-if="selectedValues.length === 0" class="text-muted-foreground text-sm">
|
||||
{{ isReadonly ? 'None selected' : placeholder }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Add button for editable chips -->
|
||||
<DropdownMenuRoot v-if="!isReadonly && displayFormat === 'chips'" v-model:open="dropdownOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="outline" size="sm" class="h-8">
|
||||
<span>Add Item</span>
|
||||
<ChevronDownIcon class="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-56 max-h-[20rem] overflow-y-auto">
|
||||
<DropdownMenuLabel>Select Items</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:model-value="selectedValues.includes(option.value)"
|
||||
@update:model-value="
|
||||
(checked) => {
|
||||
if (checked && !selectedValues.includes(option.value)) {
|
||||
selectedValues = [...selectedValues, option.value];
|
||||
} else if (!checked && selectedValues.includes(option.value)) {
|
||||
selectedValues = selectedValues.filter((v) => v !== option.value);
|
||||
}
|
||||
}
|
||||
"
|
||||
@select.prevent
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span>{{ option.label }}</span>
|
||||
<span v-if="option.description" class="text-xs text-muted-foreground">
|
||||
{{ option.description }}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuRoot>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown format -->
|
||||
<div v-else>
|
||||
<DropdownMenuRoot v-model:open="dropdownOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
:aria-expanded="dropdownOpen"
|
||||
:disabled="isReadonly"
|
||||
class="w-full justify-between"
|
||||
>
|
||||
<span class="truncate">
|
||||
<template v-if="selectedValues.length === 0">
|
||||
{{ placeholder }}
|
||||
</template>
|
||||
<template v-else-if="selectedValues.length === 1">
|
||||
{{ options.find((o) => o.value === selectedValues[0])?.label || selectedValues[0] }}
|
||||
</template>
|
||||
<template v-else> {{ selectedValues.length }} items selected </template>
|
||||
</span>
|
||||
<ChevronDownIcon class="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent class="w-full max-h-[20rem] overflow-y-auto">
|
||||
<DropdownMenuLabel class="flex justify-between items-center">
|
||||
<span>Select Items</span>
|
||||
<button
|
||||
v-if="selectedValues.length > 0"
|
||||
@click="clearAll"
|
||||
class="text-xs text-muted-foreground hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuCheckboxItem
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:model-value="selectedValues.includes(option.value)"
|
||||
@update:model-value="
|
||||
(checked) => {
|
||||
if (checked && !selectedValues.includes(option.value)) {
|
||||
selectedValues = [...selectedValues, option.value];
|
||||
} else if (!checked && selectedValues.includes(option.value)) {
|
||||
selectedValues = selectedValues.filter((v) => v !== option.value);
|
||||
}
|
||||
}
|
||||
"
|
||||
@select.prevent
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span>{{ option.label }}</span>
|
||||
<span v-if="option.description" class="text-xs text-muted-foreground">
|
||||
{{ option.description }}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
|
||||
<template v-if="selectedValues.length > 0">
|
||||
<DropdownMenuSeparator />
|
||||
<div class="px-2 py-2 text-sm text-muted-foreground">{{ selectedValues.length }} selected</div>
|
||||
</template>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuRoot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -169,7 +169,7 @@ const updateItem = (index: number, newValue: unknown) => {
|
||||
<div class="w-full">
|
||||
<Tabs v-if="items.length > 0" v-model="activeTab" class="w-full">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<TabsList class="flex-1">
|
||||
<TabsList class="flex-1 flex-wrap">
|
||||
<TabsTrigger
|
||||
v-for="(item, index) in items"
|
||||
:key="index"
|
||||
@@ -196,7 +196,7 @@ const updateItem = (index: number, newValue: unknown) => {
|
||||
:value="String(index)"
|
||||
class="mt-0 w-full"
|
||||
>
|
||||
<div class="border rounded-lg p-6 w-full">
|
||||
<div class="border rounded-lg p-1 sm:p-6 w-full">
|
||||
<div class="flex justify-end mb-4">
|
||||
<Button
|
||||
v-if="!isItemProtected(item)"
|
||||
|
||||
@@ -20,10 +20,11 @@ const selected = computed(() => control.value.data);
|
||||
const options = computed(() => {
|
||||
const enumValues: string[] = control.value.schema.enum || [];
|
||||
const tooltips: string[] | undefined = control.value.uischema.options?.tooltips;
|
||||
const labels: Record<string, string> | undefined = control.value.uischema.options?.labels;
|
||||
|
||||
return enumValues.map((value, index) => ({
|
||||
value,
|
||||
label: value,
|
||||
label: labels?.[value] || value,
|
||||
tooltip: tooltips && tooltips[index] ? tooltips[index] : undefined,
|
||||
}));
|
||||
});
|
||||
@@ -41,7 +42,9 @@ const onChange = (value: unknown) => {
|
||||
@update:model-value="onChange"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue v-if="selected">{{ selected }}</SelectValue>
|
||||
<SelectValue v-if="selected">{{
|
||||
options.find((o) => o.value === selected)?.label || selected
|
||||
}}</SelectValue>
|
||||
<span v-else>{{ control.schema.default ?? 'Select an option' }}</span>
|
||||
</SelectTrigger>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import HorizontalLayout from '@/forms/HorizontalLayout.vue';
|
||||
import inputFieldRenderer from '@/forms/InputField.vue';
|
||||
import LabelRenderer from '@/forms/LabelRenderer.vue';
|
||||
import MissingRenderer from '@/forms/MissingRenderer.vue';
|
||||
import MultiSelect from '@/forms/MultiSelect.vue';
|
||||
import numberFieldRenderer from '@/forms/NumberField.vue';
|
||||
import ObjectArrayField from '@/forms/ObjectArrayField.vue';
|
||||
import PreconditionsLabel from '@/forms/PreconditionsLabel.vue';
|
||||
@@ -55,6 +56,12 @@ const isObjectArray = (schema: JsonSchema): boolean => {
|
||||
return schema.type === 'array' && items?.type === 'object';
|
||||
};
|
||||
|
||||
const isEnumArray = (schema: JsonSchema): boolean => {
|
||||
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return false;
|
||||
const items = schema.items as JsonSchema;
|
||||
return schema.type === 'array' && items?.enum !== undefined;
|
||||
};
|
||||
|
||||
const isStringOrAnyOfString = (schema: JsonSchema): boolean => {
|
||||
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return false;
|
||||
// Exclude enum fields - they should use select renderer
|
||||
@@ -102,6 +109,16 @@ export const jsonFormsRenderers: JsonFormsRendererRegistryEntry[] = [
|
||||
renderer: markRaw(withErrorWrapper(switchRenderer)),
|
||||
tester: rankWith(4, and(isBooleanControl, optionIs('format', 'toggle'))),
|
||||
},
|
||||
// MultiSelect for array enums or when multiple option is set
|
||||
{
|
||||
renderer: markRaw(withErrorWrapper(MultiSelect)),
|
||||
tester: rankWith(8, and(isControl, schemaMatches(isEnumArray))),
|
||||
},
|
||||
{
|
||||
renderer: markRaw(withErrorWrapper(MultiSelect)),
|
||||
tester: rankWith(8, and(isControl, optionIs('multiple', true))),
|
||||
},
|
||||
// Regular select for single enums
|
||||
{
|
||||
renderer: markRaw(withErrorWrapper(selectRenderer)),
|
||||
tester: rankWith(6, isEnumControl),
|
||||
|
||||
@@ -13,3 +13,5 @@ export * from '@/lib/utils';
|
||||
|
||||
// Composables
|
||||
export { default as useTeleport } from '@/composables/useTeleport';
|
||||
export { useToast } from '@/composables/useToast';
|
||||
export type { ToastInstance } from '@/composables/useToast';
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { encodePermissionsToScopes, decodeScopesToPermissions } from '../utils/authorizationScopes';
|
||||
import { AuthAction, Resource } from '../composables/gql/graphql';
|
||||
|
||||
describe('authorizationScopes', () => {
|
||||
describe('encodePermissionsToScopes', () => {
|
||||
describe('duplicate handling', () => {
|
||||
it('should deduplicate action verbs when multiple permissions have duplicate actions', () => {
|
||||
const permissions = [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY, AuthAction.READ_OWN, AuthAction.UPDATE_ANY]
|
||||
}
|
||||
];
|
||||
|
||||
const scopes = encodePermissionsToScopes([], permissions);
|
||||
|
||||
// Should produce "docker:read_any+read_own+update_any" with distinct actions preserved
|
||||
expect(scopes).toHaveLength(1);
|
||||
expect(scopes[0]).toBe('docker:read_any+read_own+update_any');
|
||||
});
|
||||
|
||||
it('should deduplicate resource names when grouped', () => {
|
||||
const permissions = [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY]
|
||||
},
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_OWN]
|
||||
}
|
||||
];
|
||||
|
||||
const scopes = encodePermissionsToScopes([], permissions);
|
||||
|
||||
// Should produce "docker:read_any+read_own" merging both permissions
|
||||
expect(scopes).toHaveLength(1);
|
||||
expect(scopes[0]).toBe('docker:read_any+read_own');
|
||||
});
|
||||
|
||||
it('should handle multiple duplicate resources and actions correctly', () => {
|
||||
const permissions = [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY, AuthAction.READ_OWN, AuthAction.UPDATE_ANY]
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_OWN, AuthAction.UPDATE_ANY]
|
||||
},
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_OWN, AuthAction.UPDATE_OWN]
|
||||
}
|
||||
];
|
||||
|
||||
const scopes = encodePermissionsToScopes([], permissions);
|
||||
|
||||
// Docker: READ_ANY, READ_OWN, UPDATE_ANY, UPDATE_OWN (merged from both)
|
||||
// VMS: READ_ANY, UPDATE_OWN, UPDATE_ANY
|
||||
// Different action sets, so separate scopes
|
||||
expect(scopes).toHaveLength(2);
|
||||
const scopeStrings = scopes.sort();
|
||||
expect(scopeStrings).toContain('docker:read_any+read_own+update_any+update_own');
|
||||
expect(scopeStrings).toContain('vms:read_any+update_any+update_own');
|
||||
});
|
||||
|
||||
it('should maintain consistent sorting for actions and resources', () => {
|
||||
const permissions = [
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.UPDATE_ANY, AuthAction.READ_ANY]
|
||||
},
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.UPDATE_ANY, AuthAction.READ_ANY]
|
||||
}
|
||||
];
|
||||
|
||||
const scopes = encodePermissionsToScopes([], permissions);
|
||||
|
||||
// Should sort resources (docker before vms) and actions alphabetically
|
||||
expect(scopes).toHaveLength(1);
|
||||
expect(scopes[0]).toBe('docker+vms:read_any+update_any');
|
||||
});
|
||||
|
||||
it('should handle all CRUD actions without creating wildcard', () => {
|
||||
const permissions = [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [
|
||||
AuthAction.CREATE_ANY, AuthAction.CREATE_OWN,
|
||||
AuthAction.READ_ANY, AuthAction.READ_OWN,
|
||||
AuthAction.UPDATE_ANY, AuthAction.UPDATE_OWN,
|
||||
AuthAction.DELETE_ANY, AuthAction.DELETE_OWN
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const scopes = encodePermissionsToScopes([], permissions);
|
||||
|
||||
// Should just list all actions, no wildcard conversion
|
||||
expect(scopes).toHaveLength(1);
|
||||
expect(scopes[0]).toBe('docker:create_any+create_own+delete_any+delete_own+read_any+read_own+update_any+update_own');
|
||||
});
|
||||
|
||||
it('should handle edge case with empty actions after deduplication', () => {
|
||||
const permissions = [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: []
|
||||
}
|
||||
];
|
||||
|
||||
const scopes = encodePermissionsToScopes([], permissions);
|
||||
|
||||
// Should not produce any scope for empty actions
|
||||
expect(scopes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should deduplicate resources across multiple permissions with same action set', () => {
|
||||
const permissions = [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY]
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.READ_OWN]
|
||||
},
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_OWN]
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.READ_ANY]
|
||||
}
|
||||
];
|
||||
|
||||
const scopes = encodePermissionsToScopes([], permissions);
|
||||
|
||||
// Both DOCKER and VMS have READ_ANY and READ_OWN, should group without duplicates
|
||||
expect(scopes).toHaveLength(1);
|
||||
expect(scopes[0]).toBe('docker+vms:read_any+read_own');
|
||||
});
|
||||
|
||||
it('should produce deterministic output for same input regardless of order', () => {
|
||||
const permissions1 = [
|
||||
{ resource: Resource.VMS, actions: [AuthAction.UPDATE_ANY, AuthAction.READ_ANY] },
|
||||
{ resource: Resource.DOCKER, actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY] }
|
||||
];
|
||||
|
||||
const permissions2 = [
|
||||
{ resource: Resource.DOCKER, actions: [AuthAction.UPDATE_ANY, AuthAction.READ_ANY] },
|
||||
{ resource: Resource.VMS, actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY] }
|
||||
];
|
||||
|
||||
const scopes1 = encodePermissionsToScopes([], permissions1);
|
||||
const scopes2 = encodePermissionsToScopes([], permissions2);
|
||||
|
||||
expect(scopes1).toEqual(scopes2);
|
||||
expect(scopes1[0]).toBe('docker+vms:read_any+update_any');
|
||||
});
|
||||
});
|
||||
|
||||
describe('roundtrip encoding/decoding', () => {
|
||||
it('should maintain permissions through encode/decode cycle with duplicates', () => {
|
||||
const originalPermissions = [
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY, AuthAction.READ_OWN, AuthAction.UPDATE_ANY]
|
||||
},
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.READ_OWN, AuthAction.UPDATE_OWN, AuthAction.UPDATE_ANY]
|
||||
}
|
||||
];
|
||||
|
||||
const scopes = encodePermissionsToScopes([], originalPermissions);
|
||||
const { permissions: decoded } = decodeScopesToPermissions(scopes);
|
||||
|
||||
// Now possession is preserved in the encoding
|
||||
expect(decoded).toHaveLength(2);
|
||||
|
||||
const dockerPerm = decoded.find(p => p.resource === Resource.DOCKER);
|
||||
const vmsPerm = decoded.find(p => p.resource === Resource.VMS);
|
||||
|
||||
// Docker should have its specific actions preserved
|
||||
expect(dockerPerm?.actions).toContain(AuthAction.READ_ANY);
|
||||
expect(dockerPerm?.actions).toContain(AuthAction.READ_OWN);
|
||||
expect(dockerPerm?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
|
||||
// VMS should have its specific actions preserved
|
||||
expect(vmsPerm?.actions).toContain(AuthAction.READ_OWN);
|
||||
expect(vmsPerm?.actions).toContain(AuthAction.UPDATE_OWN);
|
||||
expect(vmsPerm?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
|
||||
// The scopes should be separate since they have different action sets
|
||||
expect(scopes).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { nextTick } from 'vue';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { Input, Label, Select, SelectTrigger, Switch } from '@unraid/ui';
|
||||
import { Input, Label, Select, Switch } from '@unraid/ui';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -68,6 +68,12 @@ describe('ColorSwitcher', () => {
|
||||
const wrapper = mount(ColorSwitcher, {
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
stubs: {
|
||||
Accordion: { template: '<div><slot /></div>' },
|
||||
AccordionItem: { template: '<div><slot /></div>' },
|
||||
AccordionTrigger: { template: '<div><slot /></div>' },
|
||||
AccordionContent: { template: '<div><slot /></div>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -80,13 +86,22 @@ describe('ColorSwitcher', () => {
|
||||
const switches = wrapper.findAllComponents(Switch);
|
||||
expect(switches).toHaveLength(3);
|
||||
|
||||
expect(wrapper.findComponent(SelectTrigger).exists()).toBe(true);
|
||||
expect(wrapper.findComponent(Select).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('updates theme store when theme selection changes', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn });
|
||||
setActivePinia(pinia);
|
||||
themeStore = useThemeStore();
|
||||
|
||||
const wrapper = mount(ColorSwitcher, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: {
|
||||
Accordion: { template: '<div><slot /></div>' },
|
||||
AccordionItem: { template: '<div><slot /></div>' },
|
||||
AccordionTrigger: { template: '<div><slot /></div>' },
|
||||
AccordionContent: { template: '<div><slot /></div>' },
|
||||
Select: {
|
||||
template: '<div/>',
|
||||
props: ['modelValue'],
|
||||
@@ -96,9 +111,13 @@ describe('ColorSwitcher', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Clear mocks after initial mount
|
||||
vi.clearAllMocks();
|
||||
|
||||
const selectComponent = wrapper.findComponent(Select);
|
||||
await selectComponent.vm.$emit('update:modelValue', 'black');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(themeStore.setTheme).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -114,8 +133,20 @@ describe('ColorSwitcher', () => {
|
||||
});
|
||||
|
||||
it('updates theme store when color inputs change', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn });
|
||||
setActivePinia(pinia);
|
||||
themeStore = useThemeStore();
|
||||
|
||||
const wrapper = mount(ColorSwitcher, {
|
||||
global: {},
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: {
|
||||
Accordion: { template: '<div><slot /></div>' },
|
||||
AccordionItem: { template: '<div><slot /></div>' },
|
||||
AccordionTrigger: { template: '<div><slot /></div>' },
|
||||
AccordionContent: { template: '<div><slot /></div>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const inputs = wrapper.findAllComponents(Input);
|
||||
@@ -123,19 +154,25 @@ describe('ColorSwitcher', () => {
|
||||
const secondaryTextInput = inputs[1];
|
||||
const bgInput = inputs[2];
|
||||
|
||||
await primaryTextInput.setValue('#ff0000');
|
||||
// Clear mocks after initial mount
|
||||
vi.clearAllMocks();
|
||||
|
||||
await primaryTextInput.vm.$emit('update:modelValue', '#ff0000');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(themeStore.setTheme).toHaveBeenCalledTimes(2);
|
||||
expect(themeStore.setTheme).toHaveBeenCalledWith(expect.objectContaining({ textColor: '#ff0000' }));
|
||||
|
||||
await secondaryTextInput.setValue('#00ff00');
|
||||
await secondaryTextInput.vm.$emit('update:modelValue', '#00ff00');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(themeStore.setTheme).toHaveBeenCalledTimes(3);
|
||||
expect(themeStore.setTheme).toHaveBeenCalledWith(expect.objectContaining({ metaColor: '#00ff00' }));
|
||||
|
||||
await bgInput.setValue('#0000ff');
|
||||
await bgInput.vm.$emit('update:modelValue', '#0000ff');
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
expect(themeStore.setTheme).toHaveBeenCalledTimes(4);
|
||||
@@ -154,7 +191,15 @@ describe('ColorSwitcher', () => {
|
||||
|
||||
it('updates theme store when switches change', async () => {
|
||||
const wrapper = mount(ColorSwitcher, {
|
||||
global: {},
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn })],
|
||||
stubs: {
|
||||
Accordion: { template: '<div><slot /></div>' },
|
||||
AccordionItem: { template: '<div><slot /></div>' },
|
||||
AccordionTrigger: { template: '<div><slot /></div>' },
|
||||
AccordionContent: { template: '<div><slot /></div>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
themeStore = useThemeStore();
|
||||
@@ -189,12 +234,23 @@ describe('ColorSwitcher', () => {
|
||||
});
|
||||
|
||||
it('enables gradient automatically when banner is enabled', async () => {
|
||||
const wrapper = mount(ColorSwitcher, {
|
||||
global: {},
|
||||
});
|
||||
|
||||
// Create a single Pinia instance to be shared between component and store
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn });
|
||||
setActivePinia(pinia);
|
||||
themeStore = useThemeStore();
|
||||
|
||||
const wrapper = mount(ColorSwitcher, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
stubs: {
|
||||
Accordion: { template: '<div><slot /></div>' },
|
||||
AccordionItem: { template: '<div><slot /></div>' },
|
||||
AccordionTrigger: { template: '<div><slot /></div>' },
|
||||
AccordionContent: { template: '<div><slot /></div>' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const switches = wrapper.findAllComponents(Switch);
|
||||
const gradientSwitch = switches[0];
|
||||
const bannerSwitch = switches[2];
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ref } from 'vue';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { provideApolloClient } from '@vue/apollo-composable';
|
||||
import { ApolloClient, InMemoryCache } from '@apollo/client/core';
|
||||
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -93,6 +95,22 @@ describe('UserProfile.ce.vue', () => {
|
||||
let consoleSpies: Array<ReturnType<typeof vi.spyOn>> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
// Create a mock Apollo Client
|
||||
const mockApolloClient = new ApolloClient({
|
||||
cache: new InMemoryCache(),
|
||||
defaultOptions: {
|
||||
query: {
|
||||
fetchPolicy: 'no-cache',
|
||||
},
|
||||
watchQuery: {
|
||||
fetchPolicy: 'no-cache',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Provide the Apollo client globally
|
||||
provideApolloClient(mockApolloClient);
|
||||
|
||||
// Suppress all console outputs
|
||||
consoleSpies = [
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {}),
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { useApiKeyAuthorization } from '~/composables/useApiKeyAuthorization';
|
||||
import { AuthAction, Resource, Role } from '~/composables/gql/graphql';
|
||||
|
||||
describe('useApiKeyAuthorization', () => {
|
||||
describe('parameter parsing', () => {
|
||||
it('should parse query parameters correctly', () => {
|
||||
const params = new URLSearchParams('?name=TestApp&scopes=docker:read,vms:*&redirect_uri=https://example.com&state=abc123');
|
||||
const { authParams } = useApiKeyAuthorization(params);
|
||||
|
||||
expect(authParams.value.name).toBe('TestApp');
|
||||
expect(authParams.value.scopes).toEqual(['docker:read', 'vms:*']);
|
||||
expect(authParams.value.redirectUri).toBe('https://example.com');
|
||||
expect(authParams.value.state).toBe('abc123');
|
||||
});
|
||||
|
||||
it('should handle missing parameters with defaults', () => {
|
||||
const params = new URLSearchParams('');
|
||||
const { authParams } = useApiKeyAuthorization(params);
|
||||
|
||||
expect(authParams.value.name).toBe('Unknown Application');
|
||||
expect(authParams.value.scopes).toEqual([]);
|
||||
expect(authParams.value.redirectUri).toBe('');
|
||||
expect(authParams.value.state).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPermissions', () => {
|
||||
it('should format role scopes correctly', () => {
|
||||
const params = new URLSearchParams('?scopes=role:admin,role:viewer');
|
||||
const { formattedPermissions } = useApiKeyAuthorization(params);
|
||||
|
||||
expect(formattedPermissions.value).toEqual([
|
||||
{
|
||||
scope: 'role:admin',
|
||||
name: 'ADMIN',
|
||||
description: 'Grant admin role access',
|
||||
isRole: true,
|
||||
},
|
||||
{
|
||||
scope: 'role:viewer',
|
||||
name: 'VIEWER',
|
||||
description: 'Grant viewer role access',
|
||||
isRole: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should format resource:action scopes correctly', () => {
|
||||
const params = new URLSearchParams('?scopes=docker:read,vms:*');
|
||||
const { formattedPermissions } = useApiKeyAuthorization(params);
|
||||
|
||||
expect(formattedPermissions.value).toEqual([
|
||||
{
|
||||
scope: 'docker:read',
|
||||
name: 'Docker - Read',
|
||||
description: 'Read access to Docker',
|
||||
isRole: false,
|
||||
},
|
||||
{
|
||||
scope: 'vms:*',
|
||||
name: 'Vms - Full',
|
||||
description: 'Full access to Vms',
|
||||
isRole: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertScopesToPermissions', () => {
|
||||
it('should convert role scopes to roles', () => {
|
||||
const params = new URLSearchParams('?scopes=role:admin');
|
||||
const { convertScopesToPermissions } = useApiKeyAuthorization(params);
|
||||
const result = convertScopesToPermissions(['role:admin']);
|
||||
|
||||
expect(result.roles).toContain(Role.ADMIN);
|
||||
expect(result.permissions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should convert resource scopes to permissions', () => {
|
||||
const params = new URLSearchParams('?scopes=docker:read');
|
||||
const { convertScopesToPermissions } = useApiKeyAuthorization(params);
|
||||
const result = convertScopesToPermissions(['docker:read']);
|
||||
|
||||
expect(result.permissions).toEqual([
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY],
|
||||
},
|
||||
]);
|
||||
expect(result.roles).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle wildcard actions', () => {
|
||||
const params = new URLSearchParams('?scopes=vms:*');
|
||||
const { convertScopesToPermissions } = useApiKeyAuthorization(params);
|
||||
const result = convertScopesToPermissions(['vms:*']);
|
||||
|
||||
expect(result.permissions).toEqual([
|
||||
{
|
||||
resource: Resource.VMS,
|
||||
actions: [AuthAction.CREATE_ANY, AuthAction.READ_ANY, AuthAction.UPDATE_ANY, AuthAction.DELETE_ANY],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should merge multiple actions for same resource', () => {
|
||||
const params = new URLSearchParams('');
|
||||
const { convertScopesToPermissions } = useApiKeyAuthorization(params);
|
||||
const result = convertScopesToPermissions(['docker:read', 'docker:update']);
|
||||
|
||||
expect(result.permissions).toEqual([
|
||||
{
|
||||
resource: Resource.DOCKER,
|
||||
actions: [AuthAction.READ_ANY, AuthAction.UPDATE_ANY],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redirect URI validation', () => {
|
||||
it('should accept HTTPS URLs', () => {
|
||||
const params = new URLSearchParams('?redirect_uri=https://example.com/callback');
|
||||
const { hasValidRedirectUri } = useApiKeyAuthorization(params);
|
||||
|
||||
expect(hasValidRedirectUri.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept localhost URLs', () => {
|
||||
const params = new URLSearchParams('?redirect_uri=http://localhost:3000/callback');
|
||||
const { hasValidRedirectUri } = useApiKeyAuthorization(params);
|
||||
|
||||
expect(hasValidRedirectUri.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept HTTP URLs (non-localhost)', () => {
|
||||
const params = new URLSearchParams('?redirect_uri=http://example.com/callback');
|
||||
const { hasValidRedirectUri } = useApiKeyAuthorization(params);
|
||||
|
||||
expect(hasValidRedirectUri.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid URLs', () => {
|
||||
const params = new URLSearchParams('?redirect_uri=not-a-url');
|
||||
const { hasValidRedirectUri } = useApiKeyAuthorization(params);
|
||||
|
||||
expect(hasValidRedirectUri.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCallbackUrl', () => {
|
||||
it('should build callback URL with API key', () => {
|
||||
const params = new URLSearchParams('');
|
||||
const { buildCallbackUrl } = useApiKeyAuthorization(params);
|
||||
const url = buildCallbackUrl('https://example.com/callback', 'test-key', undefined, 'state123');
|
||||
|
||||
expect(url).toBe('https://example.com/callback?api_key=test-key&state=state123');
|
||||
});
|
||||
|
||||
it('should build callback URL with error', () => {
|
||||
const params = new URLSearchParams('');
|
||||
const { buildCallbackUrl } = useApiKeyAuthorization(params);
|
||||
const url = buildCallbackUrl('https://example.com/callback', undefined, 'access_denied', 'state123');
|
||||
|
||||
expect(url).toBe('https://example.com/callback?error=access_denied&state=state123');
|
||||
});
|
||||
|
||||
it('should throw for invalid redirect URI', () => {
|
||||
const params = new URLSearchParams('');
|
||||
const { buildCallbackUrl } = useApiKeyAuthorization(params);
|
||||
|
||||
expect(() => buildCallbackUrl('not-a-url', 'key')).toThrow('Invalid redirect URI');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { useAuthorizationLink } from '~/composables/useAuthorizationLink.js';
|
||||
import { Role, Resource, AuthAction } from '~/composables/gql/graphql.js';
|
||||
|
||||
// Mock window.location for the tests
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
search: '',
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
describe('useAuthorizationLink', () => {
|
||||
it('should convert role scopes to form data', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'MyApp',
|
||||
description: 'My test application',
|
||||
scopes: 'role:admin,role:viewer',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
});
|
||||
|
||||
const { formData, displayAppName, hasPermissions, permissionsSummary } = useAuthorizationLink(params);
|
||||
|
||||
expect(formData.value).toEqual({
|
||||
name: 'MyApp',
|
||||
description: 'My test application',
|
||||
roles: [Role.ADMIN, Role.VIEWER],
|
||||
customPermissions: [],
|
||||
});
|
||||
|
||||
expect(displayAppName.value).toBe('MyApp');
|
||||
expect(hasPermissions.value).toBe(true);
|
||||
expect(permissionsSummary.value).toBe('2 role(s)');
|
||||
});
|
||||
|
||||
it('should group resources by their action sets', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Docker Manager',
|
||||
scopes: 'docker:read_any,docker:update_any,vms:read_any',
|
||||
});
|
||||
|
||||
const { formData, hasPermissions, permissionsSummary } = useAuthorizationLink(params);
|
||||
|
||||
// docker has read_any+update_any, vms only has read_any - these should be separate groups
|
||||
expect(formData.value.customPermissions!).toHaveLength(2);
|
||||
|
||||
// Find the group with just READ_ANY
|
||||
const readOnlyGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.length === 1 && p.actions[0] === AuthAction.READ_ANY
|
||||
);
|
||||
expect(readOnlyGroup).toBeDefined();
|
||||
expect(readOnlyGroup?.resources).toEqual([Resource.VMS]);
|
||||
|
||||
// Find the group with READ_ANY and UPDATE_ANY
|
||||
const readUpdateGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.length === 2 &&
|
||||
p.actions.includes(AuthAction.READ_ANY) &&
|
||||
p.actions.includes(AuthAction.UPDATE_ANY)
|
||||
);
|
||||
expect(readUpdateGroup).toBeDefined();
|
||||
expect(readUpdateGroup?.resources).toEqual([Resource.DOCKER]);
|
||||
|
||||
expect(hasPermissions.value).toBe(true);
|
||||
expect(permissionsSummary.value).toBe('3 permission(s)');
|
||||
});
|
||||
|
||||
it('should handle mixed role and permission scopes', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Mixed Access App',
|
||||
scopes: 'role:admin,docker:read_any,vms:*',
|
||||
});
|
||||
|
||||
const { formData, hasPermissions, permissionsSummary } = useAuthorizationLink(params);
|
||||
|
||||
expect(formData.value.roles).toEqual([Role.ADMIN]);
|
||||
expect(formData.value.customPermissions!).toHaveLength(2);
|
||||
|
||||
// Docker should have just read permission
|
||||
const dockerGroup = formData.value.customPermissions!.find(
|
||||
p => p.resources.includes(Resource.DOCKER)
|
||||
);
|
||||
expect(dockerGroup).toBeDefined();
|
||||
expect(dockerGroup?.actions).toEqual([AuthAction.READ_ANY]);
|
||||
|
||||
// VMs should have all CRUD permissions from wildcard
|
||||
const vmsGroup = formData.value.customPermissions!.find(
|
||||
p => p.resources.includes(Resource.VMS)
|
||||
);
|
||||
expect(vmsGroup).toBeDefined();
|
||||
expect(vmsGroup?.actions).toContain(AuthAction.CREATE_ANY);
|
||||
expect(vmsGroup?.actions).toContain(AuthAction.READ_ANY);
|
||||
expect(vmsGroup?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
expect(vmsGroup?.actions).toContain(AuthAction.DELETE_ANY);
|
||||
|
||||
expect(hasPermissions.value).toBe(true);
|
||||
expect(permissionsSummary.value).toBe('1 role(s), 2 permission(s)');
|
||||
});
|
||||
|
||||
it('should handle wildcard permissions correctly', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Full Access App',
|
||||
scopes: 'docker:*',
|
||||
});
|
||||
|
||||
const { hasPermissions, permissionsSummary } = useAuthorizationLink(params);
|
||||
|
||||
expect(hasPermissions.value).toBe(true);
|
||||
expect(permissionsSummary.value).toBe('1 permission(s)');
|
||||
});
|
||||
|
||||
it('should handle empty scopes gracefully', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'No Permissions App',
|
||||
scopes: '',
|
||||
});
|
||||
|
||||
const { formData, hasPermissions, permissionsSummary } = useAuthorizationLink(params);
|
||||
|
||||
expect(formData.value).toEqual({
|
||||
name: 'No Permissions App',
|
||||
description: '',
|
||||
roles: [],
|
||||
customPermissions: [],
|
||||
});
|
||||
|
||||
expect(hasPermissions.value).toBe(false);
|
||||
expect(permissionsSummary.value).toBe('');
|
||||
});
|
||||
|
||||
it('should handle app names ending with " API Key"', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'MyApp API Key',
|
||||
scopes: 'role:viewer',
|
||||
});
|
||||
|
||||
const { formData, displayAppName } = useAuthorizationLink(params);
|
||||
|
||||
expect(displayAppName.value).toBe('MyApp');
|
||||
// Name should be used as-is without appending
|
||||
expect(formData.value.name).toBe('MyApp API Key');
|
||||
});
|
||||
|
||||
it('should handle invalid scopes gracefully', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Invalid Scopes App',
|
||||
scopes: 'role:invalid_role,unknown_resource:read,docker:invalid_action',
|
||||
});
|
||||
|
||||
const { hasPermissions, permissionsSummary } = useAuthorizationLink(params);
|
||||
|
||||
expect(hasPermissions.value).toBe(true); // Has scopes, even if invalid
|
||||
expect(permissionsSummary.value).toBe('1 role(s), 2 permission(s)');
|
||||
});
|
||||
|
||||
it('should use default values when parameters are missing', () => {
|
||||
const params = new URLSearchParams(); // Empty params
|
||||
|
||||
const { formData, displayAppName } = useAuthorizationLink(params);
|
||||
|
||||
expect(formData.value.name).toBe('Unknown Application');
|
||||
expect(displayAppName.value).toBe('Unknown Application');
|
||||
});
|
||||
|
||||
describe('permission grouping and preservation', () => {
|
||||
it('should group multiple resources with same actions into single permission group', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Multi-Resource Reader',
|
||||
scopes: 'connect:read_any,disk:read_any,docker:read_any',
|
||||
});
|
||||
|
||||
const { formData } = useAuthorizationLink(params);
|
||||
|
||||
// All have same action (read), so should be in one group
|
||||
expect(formData.value.customPermissions!).toHaveLength(1);
|
||||
expect(formData.value.customPermissions![0]).toEqual({
|
||||
resources: [Resource.CONNECT, Resource.DISK, Resource.DOCKER],
|
||||
actions: [AuthAction.READ_ANY],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create separate groups for resources with different action sets', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Mixed Actions App',
|
||||
scopes: 'docker:read_any,docker:update_any,vms:create_any,vms:delete_any',
|
||||
});
|
||||
|
||||
const { formData } = useAuthorizationLink(params);
|
||||
|
||||
// Docker has read+update, VMs has create+delete - these should be separate
|
||||
expect(formData.value.customPermissions!).toHaveLength(2);
|
||||
|
||||
const dockerGroup = formData.value.customPermissions!.find(
|
||||
p => p.resources.includes(Resource.DOCKER)
|
||||
);
|
||||
expect(dockerGroup).toBeDefined();
|
||||
expect(dockerGroup?.actions).toContain(AuthAction.READ_ANY);
|
||||
expect(dockerGroup?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
|
||||
const vmsGroup = formData.value.customPermissions!.find(
|
||||
p => p.resources.includes(Resource.VMS)
|
||||
);
|
||||
expect(vmsGroup).toBeDefined();
|
||||
expect(vmsGroup?.actions).toContain(AuthAction.CREATE_ANY);
|
||||
expect(vmsGroup?.actions).toContain(AuthAction.DELETE_ANY);
|
||||
});
|
||||
|
||||
it('should handle duplicate scopes correctly', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Duplicate Scopes App',
|
||||
scopes: 'docker:read_any,docker:read_any,vms:update_any,vms:update_any',
|
||||
});
|
||||
|
||||
const { formData } = useAuthorizationLink(params);
|
||||
|
||||
// Docker has read, VMs has update - different actions so separate groups
|
||||
expect(formData.value.customPermissions!).toHaveLength(2);
|
||||
|
||||
const readGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.includes(AuthAction.READ_ANY)
|
||||
);
|
||||
expect(readGroup?.resources).toEqual([Resource.DOCKER]);
|
||||
|
||||
const updateGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.includes(AuthAction.UPDATE_ANY)
|
||||
);
|
||||
expect(updateGroup?.resources).toEqual([Resource.VMS]);
|
||||
});
|
||||
|
||||
it('should preserve wildcard expansion for resources', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Wildcard App',
|
||||
scopes: 'docker:*,vms:read_any',
|
||||
});
|
||||
|
||||
const { formData } = useAuthorizationLink(params);
|
||||
|
||||
// Docker has all CRUD, VMs has just read - different action sets so separate groups
|
||||
expect(formData.value.customPermissions!).toHaveLength(2);
|
||||
|
||||
const dockerGroup = formData.value.customPermissions!.find(
|
||||
p => p.resources.includes(Resource.DOCKER)
|
||||
);
|
||||
expect(dockerGroup).toBeDefined();
|
||||
// Should have all CRUD actions from wildcard
|
||||
expect(dockerGroup?.actions).toContain(AuthAction.CREATE_ANY);
|
||||
expect(dockerGroup?.actions).toContain(AuthAction.READ_ANY);
|
||||
expect(dockerGroup?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
expect(dockerGroup?.actions).toContain(AuthAction.DELETE_ANY);
|
||||
|
||||
const vmsGroup = formData.value.customPermissions!.find(
|
||||
p => p.resources.includes(Resource.VMS)
|
||||
);
|
||||
expect(vmsGroup).toBeDefined();
|
||||
expect(vmsGroup?.actions).toEqual([AuthAction.READ_ANY]);
|
||||
});
|
||||
|
||||
it('should handle complex permission combinations', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Complex Permissions App',
|
||||
scopes: 'connect:read_any,disk:read_any,docker:*,vms:update_any,vms:delete_any,dashboard:read_any',
|
||||
});
|
||||
|
||||
const { formData } = useAuthorizationLink(params);
|
||||
|
||||
// Should group by action sets:
|
||||
// - connect, disk, dashboard all have just read (group 1)
|
||||
// - docker has all CRUD from wildcard (group 2)
|
||||
// - vms has update+delete (group 3)
|
||||
expect(formData.value.customPermissions!).toHaveLength(3);
|
||||
|
||||
// Find read-only group (connect, disk, dashboard)
|
||||
const readOnlyGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.length === 1 && p.actions[0] === AuthAction.READ_ANY
|
||||
);
|
||||
expect(readOnlyGroup).toBeDefined();
|
||||
expect(readOnlyGroup?.resources).toContain(Resource.CONNECT);
|
||||
expect(readOnlyGroup?.resources).toContain(Resource.DISK);
|
||||
expect(readOnlyGroup?.resources).toContain(Resource.DASHBOARD);
|
||||
|
||||
// Find full CRUD group (docker with wildcard)
|
||||
const fullCrudGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.length === 4 && p.resources.includes(Resource.DOCKER)
|
||||
);
|
||||
expect(fullCrudGroup).toBeDefined();
|
||||
expect(fullCrudGroup?.actions).toContain(AuthAction.CREATE_ANY);
|
||||
expect(fullCrudGroup?.actions).toContain(AuthAction.READ_ANY);
|
||||
expect(fullCrudGroup?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
expect(fullCrudGroup?.actions).toContain(AuthAction.DELETE_ANY);
|
||||
|
||||
// Find update+delete group (vms)
|
||||
const updateDeleteGroup = formData.value.customPermissions!.find(
|
||||
p => p.resources.includes(Resource.VMS)
|
||||
);
|
||||
expect(updateDeleteGroup).toBeDefined();
|
||||
expect(updateDeleteGroup?.actions).toContain(AuthAction.UPDATE_ANY);
|
||||
expect(updateDeleteGroup?.actions).toContain(AuthAction.DELETE_ANY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('efficient scope encoding', () => {
|
||||
it('should decode grouped scopes correctly', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Grouped App',
|
||||
scopes: 'docker+vms:read_any+update_any',
|
||||
});
|
||||
|
||||
const { formData } = useAuthorizationLink(params);
|
||||
|
||||
// Should decode to a single group with both resources and both actions
|
||||
expect(formData.value.customPermissions!).toHaveLength(1);
|
||||
expect(formData.value.customPermissions![0].resources).toContain(Resource.DOCKER);
|
||||
expect(formData.value.customPermissions![0].resources).toContain(Resource.VMS);
|
||||
expect(formData.value.customPermissions![0].actions).toContain(AuthAction.READ_ANY);
|
||||
expect(formData.value.customPermissions![0].actions).toContain(AuthAction.UPDATE_ANY);
|
||||
});
|
||||
|
||||
it('should handle mixed grouped and individual scopes', () => {
|
||||
const params = new URLSearchParams({
|
||||
name: 'Mixed Grouped App',
|
||||
scopes: 'docker+vms:read_any,dashboard:update_any',
|
||||
});
|
||||
|
||||
const { formData } = useAuthorizationLink(params);
|
||||
|
||||
// Should have two groups: docker+vms with read, dashboard with update
|
||||
expect(formData.value.customPermissions!).toHaveLength(2);
|
||||
|
||||
const readGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.includes(AuthAction.READ_ANY)
|
||||
);
|
||||
expect(readGroup).toBeDefined();
|
||||
expect(readGroup?.resources).toContain(Resource.DOCKER);
|
||||
expect(readGroup?.resources).toContain(Resource.VMS);
|
||||
|
||||
const updateGroup = formData.value.customPermissions!.find(
|
||||
p => p.actions.includes(AuthAction.UPDATE_ANY)
|
||||
);
|
||||
expect(updateGroup).toBeDefined();
|
||||
expect(updateGroup?.resources).toEqual([Resource.DASHBOARD]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,50 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
import { useClipboardWithToast } from '~/composables/useClipboardWithToast';
|
||||
|
||||
import {
|
||||
import { ClipboardDocumentIcon } from '@heroicons/vue/24/solid';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Button,
|
||||
Dialog,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
Button,
|
||||
Dialog,
|
||||
jsonFormsAjv,
|
||||
jsonFormsRenderers
|
||||
} from '@unraid/ui';
|
||||
import { JsonForms } from '@jsonforms/vue';
|
||||
import { extractGraphQLErrorMessage } from '~/helpers/functions';
|
||||
|
||||
import type { ApolloError } from '@apollo/client/errors';
|
||||
import type { FragmentType } from '~/composables/gql/fragment-masking';
|
||||
import type { Resource, Role } from '~/composables/gql/graphql';
|
||||
import type {
|
||||
ApiKeyFormSettings,
|
||||
AuthAction,
|
||||
CreateApiKeyInput,
|
||||
Resource,
|
||||
Role,
|
||||
} from '~/composables/gql/graphql';
|
||||
import type { ComposerTranslation } from 'vue-i18n';
|
||||
|
||||
import { useFragment } from '~/composables/gql/fragment-masking';
|
||||
import { useApiKeyPermissionPresets } from '~/composables/useApiKeyPermissionPresets';
|
||||
import { useApiKeyStore } from '~/store/apiKey';
|
||||
import {
|
||||
API_KEY_FRAGMENT,
|
||||
API_KEY_FRAGMENT_WITH_KEY,
|
||||
CREATE_API_KEY,
|
||||
GET_API_KEY_META,
|
||||
UPDATE_API_KEY,
|
||||
} from './apikey.query';
|
||||
import PermissionCounter from './PermissionCounter.vue';
|
||||
import { GET_API_KEY_CREATION_FORM_SCHEMA } from './api-key-form.query';
|
||||
import { API_KEY_FRAGMENT, CREATE_API_KEY, UPDATE_API_KEY } from './apikey.query';
|
||||
import DeveloperAuthorizationLink from './DeveloperAuthorizationLink.vue';
|
||||
import EffectivePermissions from './EffectivePermissions.vue';
|
||||
|
||||
defineProps<{ t: ComposerTranslation }>();
|
||||
interface Props {
|
||||
t?: ComposerTranslation;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = props;
|
||||
|
||||
const apiKeyStore = useApiKeyStore();
|
||||
const { modalVisible, editingKey } = storeToRefs(apiKeyStore);
|
||||
const { modalVisible, editingKey, isAuthorizationMode, authorizationData, createdKey } =
|
||||
storeToRefs(apiKeyStore);
|
||||
|
||||
const { result: apiKeyMetaResult } = useQuery(GET_API_KEY_META);
|
||||
const possibleRoles = computed(() => apiKeyMetaResult.value?.apiKeyPossibleRoles || []);
|
||||
const possiblePermissions = computed(() => apiKeyMetaResult.value?.apiKeyPossiblePermissions || []);
|
||||
// Form data that matches what the backend expects
|
||||
// This will be transformed into CreateApiKeyInput or UpdateApiKeyInput
|
||||
interface FormData extends Partial<CreateApiKeyInput> {
|
||||
keyName?: string; // Used in authorization mode
|
||||
authorizationType?: 'roles' | 'groups' | 'custom';
|
||||
permissionGroups?: string[];
|
||||
permissionPresets?: string; // For the preset dropdown
|
||||
customPermissions?: Array<{
|
||||
resources: Resource[];
|
||||
actions: AuthAction[];
|
||||
}>;
|
||||
requestedPermissions?: {
|
||||
roles?: Role[];
|
||||
permissionGroups?: string[];
|
||||
customPermissions?: Array<{
|
||||
resources: Resource[];
|
||||
actions: AuthAction[];
|
||||
}>;
|
||||
};
|
||||
consent?: boolean;
|
||||
}
|
||||
|
||||
const formSchema = ref<ApiKeyFormSettings | null>(null);
|
||||
const formData = ref<FormData>({
|
||||
customPermissions: [],
|
||||
roles: [],
|
||||
authorizationType: 'roles',
|
||||
} as FormData);
|
||||
const formValid = ref(false);
|
||||
|
||||
// Use clipboard for copying
|
||||
const { copyWithNotification, copied } = useClipboardWithToast();
|
||||
|
||||
// Computed property to transform formData permissions for the EffectivePermissions component
|
||||
const formDataPermissions = computed(() => {
|
||||
if (!formData.value.customPermissions) return [];
|
||||
|
||||
// Flatten the resources array into individual permission entries
|
||||
return formData.value.customPermissions.flatMap((perm) =>
|
||||
perm.resources.map((resource) => ({
|
||||
resource,
|
||||
actions: perm.actions, // Already string[] which can be AuthAction values
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
const newKeyName = ref('');
|
||||
const newKeyDescription = ref('');
|
||||
const newKeyRoles = ref<Role[]>([]);
|
||||
const newKeyPermissions = ref<{ resource: Resource; actions: string[] }[]>([]);
|
||||
const { mutate: createApiKey, loading: createLoading, error: createError } = useMutation(CREATE_API_KEY);
|
||||
const { mutate: updateApiKey, loading: updateLoading, error: updateError } = useMutation(UPDATE_API_KEY);
|
||||
const postCreateLoading = ref(false);
|
||||
@@ -52,154 +101,302 @@ const postCreateLoading = ref(false);
|
||||
const loading = computed<boolean>(() => createLoading.value || updateLoading.value);
|
||||
const error = computed<ApolloError | null>(() => createError.value || updateError.value);
|
||||
|
||||
// Computed property for button disabled state
|
||||
const isButtonDisabled = computed<boolean>(() => {
|
||||
// In authorization mode, only check loading states if we have a name
|
||||
if (isAuthorizationMode.value && (formData.value.name || authorizationData.value?.formData?.name)) {
|
||||
return loading.value || postCreateLoading.value;
|
||||
}
|
||||
|
||||
// Regular validation for non-authorization mode
|
||||
return loading.value || postCreateLoading.value || !formValid.value;
|
||||
});
|
||||
|
||||
// Load form schema - always use creation form
|
||||
const loadFormSchema = () => {
|
||||
// Always load creation form schema
|
||||
const { onResult, onError } = useQuery(GET_API_KEY_CREATION_FORM_SCHEMA);
|
||||
|
||||
onResult(async (result) => {
|
||||
if (result.data?.getApiKeyCreationFormSchema) {
|
||||
formSchema.value = result.data.getApiKeyCreationFormSchema;
|
||||
|
||||
if (isAuthorizationMode.value && authorizationData.value?.formData) {
|
||||
// In authorization mode, use the form data from the authorization store
|
||||
formData.value = { ...authorizationData.value.formData };
|
||||
// Ensure the name field is set for validation
|
||||
if (!formData.value.name && authorizationData.value.name) {
|
||||
formData.value.name = authorizationData.value.name;
|
||||
}
|
||||
|
||||
// In auth mode, if we have all required fields, consider it valid initially
|
||||
// JsonForms will override this if there are actual errors
|
||||
if (formData.value.name) {
|
||||
formValid.value = true;
|
||||
}
|
||||
} else if (editingKey.value) {
|
||||
// If editing, populate form data from existing key
|
||||
populateFormFromExistingKey();
|
||||
} else {
|
||||
// For new keys, initialize with empty data
|
||||
formData.value = {
|
||||
customPermissions: [],
|
||||
};
|
||||
// Set formValid to true initially for new keys
|
||||
// JsonForms will update this if there are validation errors
|
||||
formValid.value = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onError((error) => {
|
||||
console.error('Error loading creation form schema:', error);
|
||||
});
|
||||
};
|
||||
|
||||
// Initialize form on mount
|
||||
onMounted(() => {
|
||||
loadFormSchema();
|
||||
});
|
||||
|
||||
// Watch for editing key changes
|
||||
watch(
|
||||
() => editingKey.value,
|
||||
(key) => {
|
||||
const fragmentKey = key
|
||||
? useFragment(API_KEY_FRAGMENT, key as FragmentType<typeof API_KEY_FRAGMENT>)
|
||||
: null;
|
||||
if (fragmentKey) {
|
||||
newKeyName.value = fragmentKey.name;
|
||||
newKeyDescription.value = fragmentKey.description || '';
|
||||
newKeyRoles.value = [...fragmentKey.roles];
|
||||
newKeyPermissions.value = fragmentKey.permissions
|
||||
? fragmentKey.permissions.map((p) => ({
|
||||
resource: p.resource as Resource,
|
||||
actions: [...p.actions],
|
||||
}))
|
||||
: [];
|
||||
} else {
|
||||
newKeyName.value = '';
|
||||
newKeyDescription.value = '';
|
||||
newKeyRoles.value = [];
|
||||
newKeyPermissions.value = [];
|
||||
() => {
|
||||
if (!isAuthorizationMode.value) {
|
||||
populateFormFromExistingKey();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
}
|
||||
);
|
||||
|
||||
function togglePermission(resource: string, action: string, checked: boolean) {
|
||||
const res = resource as Resource;
|
||||
const perm = newKeyPermissions.value.find((p) => p.resource === res);
|
||||
if (checked) {
|
||||
if (perm) {
|
||||
if (!perm.actions.includes(action)) perm.actions.push(action);
|
||||
} else {
|
||||
newKeyPermissions.value.push({ resource: res, actions: [action] });
|
||||
}
|
||||
} else {
|
||||
if (perm) {
|
||||
perm.actions = perm.actions.filter((a) => a !== action);
|
||||
if (perm.actions.length === 0) {
|
||||
newKeyPermissions.value = newKeyPermissions.value.filter((p) => p.resource !== res);
|
||||
// Watch for authorization mode changes
|
||||
watch(
|
||||
() => isAuthorizationMode.value,
|
||||
async (newValue) => {
|
||||
if (newValue && authorizationData.value?.formData) {
|
||||
formData.value = { ...authorizationData.value.formData };
|
||||
// Ensure the name field is set for validation
|
||||
if (!formData.value.name && authorizationData.value.name) {
|
||||
formData.value.name = authorizationData.value.name;
|
||||
}
|
||||
|
||||
// Set initial valid state if we have required fields
|
||||
if (formData.value.name) {
|
||||
formValid.value = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function areAllPermissionsSelected() {
|
||||
return possiblePermissions.value.every((perm) => {
|
||||
const selected = newKeyPermissions.value.find((p) => p.resource === perm.resource)?.actions || [];
|
||||
return perm.actions.every((a) => selected.includes(a));
|
||||
});
|
||||
}
|
||||
// Watch for authorization form data changes
|
||||
watch(
|
||||
() => authorizationData.value?.formData,
|
||||
(newFormData) => {
|
||||
if (isAuthorizationMode.value && newFormData) {
|
||||
formData.value = { ...newFormData };
|
||||
// Ensure the name field is set for validation
|
||||
if (!formData.value.name && authorizationData.value?.name) {
|
||||
formData.value.name = authorizationData.value.name;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
function selectAllPermissions() {
|
||||
newKeyPermissions.value = possiblePermissions.value.map((perm) => ({
|
||||
resource: perm.resource as Resource,
|
||||
actions: [...perm.actions],
|
||||
}));
|
||||
}
|
||||
// Use the permission presets composable
|
||||
const { applyPreset } = useApiKeyPermissionPresets();
|
||||
|
||||
function clearAllPermissions() {
|
||||
newKeyPermissions.value = [];
|
||||
}
|
||||
// Watch for permission preset selection and expand into custom permissions
|
||||
watch(
|
||||
() => formData.value.permissionPresets,
|
||||
(presetId) => {
|
||||
if (!presetId || presetId === 'none') return;
|
||||
|
||||
function areAllActionsSelected(resource: string) {
|
||||
const perm = possiblePermissions.value.find((p) => p.resource === resource);
|
||||
if (!perm) return false;
|
||||
const selected = newKeyPermissions.value.find((p) => p.resource === resource)?.actions || [];
|
||||
return perm.actions.every((a) => selected.includes(a));
|
||||
}
|
||||
// Apply the preset to custom permissions
|
||||
formData.value.customPermissions = applyPreset(presetId, formData.value.customPermissions);
|
||||
|
||||
function selectAllActions(resource: string) {
|
||||
const res = resource as Resource;
|
||||
const perm = possiblePermissions.value.find((p) => p.resource === res);
|
||||
if (!perm) return;
|
||||
const idx = newKeyPermissions.value.findIndex((p) => p.resource === res);
|
||||
if (idx !== -1) {
|
||||
newKeyPermissions.value[idx].actions = [...perm.actions];
|
||||
} else {
|
||||
newKeyPermissions.value.push({ resource: res, actions: [...perm.actions] });
|
||||
// Reset the dropdown back to 'none'
|
||||
formData.value.permissionPresets = 'none';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function clearAllActions(resource: string) {
|
||||
newKeyPermissions.value = newKeyPermissions.value.filter((p) => p.resource !== resource);
|
||||
}
|
||||
// Populate form data from existing key
|
||||
const populateFormFromExistingKey = async () => {
|
||||
if (!editingKey.value || !formSchema.value) return;
|
||||
|
||||
const fragmentKey = useFragment(
|
||||
API_KEY_FRAGMENT,
|
||||
editingKey.value as FragmentType<typeof API_KEY_FRAGMENT>
|
||||
);
|
||||
if (fragmentKey) {
|
||||
// Group permissions by actions for better UI
|
||||
const permissionGroups = new Map<string, Resource[]>();
|
||||
if (fragmentKey.permissions) {
|
||||
for (const perm of fragmentKey.permissions) {
|
||||
// Create a copy of the actions array to avoid modifying read-only data
|
||||
const actionKey = [...perm.actions].sort().join(',');
|
||||
if (!permissionGroups.has(actionKey)) {
|
||||
permissionGroups.set(actionKey, []);
|
||||
}
|
||||
permissionGroups.get(actionKey)!.push(perm.resource);
|
||||
}
|
||||
}
|
||||
|
||||
const customPermissions = Array.from(permissionGroups.entries()).map(([actionKey, resources]) => ({
|
||||
resources,
|
||||
actions: actionKey.split(',') as AuthAction[], // Actions are now already in correct format
|
||||
}));
|
||||
|
||||
formData.value = {
|
||||
name: fragmentKey.name,
|
||||
description: fragmentKey.description || '',
|
||||
authorizationType: fragmentKey.roles.length > 0 ? 'roles' : 'custom',
|
||||
roles: [...fragmentKey.roles],
|
||||
customPermissions,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Transform form data to API format
|
||||
const transformFormDataForApi = (): CreateApiKeyInput => {
|
||||
const apiData: CreateApiKeyInput = {
|
||||
name: formData.value.name || formData.value.keyName || '',
|
||||
description: formData.value.description,
|
||||
roles: [],
|
||||
permissions: undefined,
|
||||
};
|
||||
|
||||
// Both authorization and regular mode now use the same form structure
|
||||
if (formData.value.roles && formData.value.roles.length > 0) {
|
||||
apiData.roles = formData.value.roles;
|
||||
}
|
||||
|
||||
// Note: permissionGroups would need to be handled by backend
|
||||
// The CreateApiKeyInput doesn't have permissionGroups field yet
|
||||
// For now, we could expand them client-side by querying the permissions
|
||||
// or add backend support to handle permission groups
|
||||
|
||||
// Always include permissions array, even if empty (for updates to clear permissions)
|
||||
if (formData.value.customPermissions) {
|
||||
// Expand resources array into individual AddPermissionInput entries
|
||||
apiData.permissions = formData.value.customPermissions.flatMap((perm) =>
|
||||
perm.resources.map((resource) => ({
|
||||
resource,
|
||||
actions: perm.actions,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
// If customPermissions is undefined or null, and we're editing,
|
||||
// we should still send an empty array to clear permissions
|
||||
if (editingKey.value) {
|
||||
apiData.permissions = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Note: expiresAt field would need to be added to CreateApiKeyInput type
|
||||
// if (formData.value.expiresAt) {
|
||||
// apiData.expiresAt = formData.value.expiresAt;
|
||||
// }
|
||||
|
||||
return apiData;
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
apiKeyStore.hideModal();
|
||||
formData.value = {} as FormData; // Reset to empty object
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
async function upsertKey() {
|
||||
// In authorization mode, skip validation if we have a name
|
||||
if (!isAuthorizationMode.value && !formValid.value) {
|
||||
return;
|
||||
}
|
||||
if (isAuthorizationMode.value && !formData.value.name) {
|
||||
console.error('Cannot authorize without a name');
|
||||
return;
|
||||
}
|
||||
|
||||
// In authorization mode, validation is enough - no separate consent field
|
||||
|
||||
postCreateLoading.value = true;
|
||||
try {
|
||||
const apiData = transformFormDataForApi();
|
||||
|
||||
const isEdit = !!editingKey.value?.id;
|
||||
|
||||
let res;
|
||||
if (isEdit && editingKey.value) {
|
||||
res = await updateApiKey({
|
||||
input: {
|
||||
id: editingKey.value.id,
|
||||
name: newKeyName.value,
|
||||
description: newKeyDescription.value,
|
||||
roles: newKeyRoles.value,
|
||||
permissions: newKeyPermissions.value.length ? newKeyPermissions.value : undefined,
|
||||
...apiData,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
res = await createApiKey({
|
||||
input: {
|
||||
name: newKeyName.value,
|
||||
description: newKeyDescription.value,
|
||||
roles: newKeyRoles.value,
|
||||
permissions: newKeyPermissions.value.length ? newKeyPermissions.value : undefined,
|
||||
},
|
||||
input: apiData,
|
||||
});
|
||||
}
|
||||
|
||||
const apiKeyResult = res?.data?.apiKey;
|
||||
if (isEdit && apiKeyResult && 'update' in apiKeyResult) {
|
||||
const fragmentData = useFragment(API_KEY_FRAGMENT_WITH_KEY, apiKeyResult.update);
|
||||
const fragmentData = useFragment(API_KEY_FRAGMENT, apiKeyResult.update);
|
||||
apiKeyStore.setCreatedKey(fragmentData);
|
||||
} else if (!isEdit && apiKeyResult && 'create' in apiKeyResult) {
|
||||
const fragmentData = useFragment(API_KEY_FRAGMENT_WITH_KEY, apiKeyResult.create);
|
||||
const fragmentData = useFragment(API_KEY_FRAGMENT, apiKeyResult.create);
|
||||
apiKeyStore.setCreatedKey(fragmentData);
|
||||
|
||||
// If in authorization mode, call the callback with the API key
|
||||
if (isAuthorizationMode.value && authorizationData.value?.onAuthorize && 'key' in fragmentData) {
|
||||
authorizationData.value.onAuthorize(fragmentData.key);
|
||||
// Don't close the modal or reset form - let the callback handle it
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
modalVisible.value = false;
|
||||
editingKey.value = null;
|
||||
newKeyName.value = '';
|
||||
newKeyDescription.value = '';
|
||||
newKeyRoles.value = [];
|
||||
newKeyPermissions.value = [];
|
||||
apiKeyStore.hideModal();
|
||||
formData.value = {} as FormData; // Reset to empty object
|
||||
} catch (error) {
|
||||
console.error('Error in upsertKey:', error);
|
||||
} finally {
|
||||
postCreateLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy API key after creation
|
||||
const copyApiKey = async () => {
|
||||
if (createdKey.value && 'key' in createdKey.value) {
|
||||
await copyWithNotification(createdKey.value.key, 'API key copied to clipboard');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Modal mode (handles both regular creation and authorization) -->
|
||||
<Dialog
|
||||
v-if="modalVisible"
|
||||
v-model="modalVisible"
|
||||
size="lg"
|
||||
:title="editingKey ? t('Edit API Key') : t('Create API Key')"
|
||||
size="xl"
|
||||
:title="
|
||||
isAuthorizationMode
|
||||
? 'Authorize API Key Access'
|
||||
: editingKey
|
||||
? t
|
||||
? t('Edit API Key')
|
||||
: 'Edit API Key'
|
||||
: t
|
||||
? t('Create API Key')
|
||||
: 'Create API Key'
|
||||
"
|
||||
:scrollable="true"
|
||||
close-button-text="Cancel"
|
||||
:primary-button-text="editingKey ? 'Save' : 'Create'"
|
||||
:primary-button-text="isAuthorizationMode ? 'Authorize' : editingKey ? 'Save' : 'Create'"
|
||||
:primary-button-loading="loading || postCreateLoading"
|
||||
:primary-button-loading-text="editingKey ? 'Saving...' : 'Creating...'"
|
||||
:primary-button-disabled="loading || postCreateLoading"
|
||||
:primary-button-loading-text="
|
||||
isAuthorizationMode ? 'Authorizing...' : editingKey ? 'Saving...' : 'Creating...'
|
||||
"
|
||||
:primary-button-disabled="isButtonDisabled"
|
||||
@update:model-value="
|
||||
(v) => {
|
||||
if (!v) close();
|
||||
@@ -207,103 +404,117 @@ async function upsertKey() {
|
||||
"
|
||||
@primary-click="upsertKey"
|
||||
>
|
||||
<div class="max-w-[800px]">
|
||||
<form @submit.prevent="upsertKey">
|
||||
<div class="mb-2">
|
||||
<Label for="api-key-name">Name</Label>
|
||||
<Input id="api-key-name" v-model="newKeyName" placeholder="Name" class="mt-1" />
|
||||
<div class="w-full">
|
||||
<!-- Show authorization description if in authorization mode -->
|
||||
<div
|
||||
v-if="isAuthorizationMode && formSchema?.dataSchema?.description"
|
||||
class="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg"
|
||||
>
|
||||
<p class="text-sm">{{ formSchema.dataSchema.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Form based on schema -->
|
||||
<div
|
||||
v-if="formSchema"
|
||||
class="[&_.vertical-layout]:space-y-4"
|
||||
@click.stop
|
||||
@mousedown.stop
|
||||
@focus.stop
|
||||
>
|
||||
<JsonForms
|
||||
:schema="formSchema.dataSchema"
|
||||
:uischema="formSchema.uiSchema"
|
||||
:renderers="jsonFormsRenderers"
|
||||
:data="formData"
|
||||
:ajv="jsonFormsAjv"
|
||||
@change="
|
||||
({ data, errors }) => {
|
||||
formData = data;
|
||||
formValid = errors ? errors.length === 0 : true;
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-else class="flex items-center justify-center py-8">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" />
|
||||
<p class="text-sm text-muted-foreground">Loading form...</p>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<Label for="api-key-desc">Description</Label>
|
||||
<Input id="api-key-desc" v-model="newKeyDescription" placeholder="Description" class="mt-1" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<Label for="api-key-roles">Roles</Label>
|
||||
<Select
|
||||
v-model="newKeyRoles"
|
||||
:items="possibleRoles"
|
||||
:multiple="true"
|
||||
:placeholder="'Select Roles'"
|
||||
class="mt-1 w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<Label for="api-key-permissions">Permissions</Label>
|
||||
<Accordion id="api-key-permissions" type="single" collapsible class="w-full mt-2">
|
||||
<AccordionItem value="permissions">
|
||||
<AccordionTrigger>
|
||||
<PermissionCounter
|
||||
:permissions="newKeyPermissions"
|
||||
:possible-permissions="possiblePermissions"
|
||||
/>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div class="flex flex-row justify-end my-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
@click="areAllPermissionsSelected() ? clearAllPermissions() : selectAllPermissions()"
|
||||
>
|
||||
{{ areAllPermissionsSelected() ? 'Select None' : 'Select All' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 mt-1">
|
||||
<div
|
||||
v-for="perm in possiblePermissions"
|
||||
:key="perm.resource"
|
||||
class="rounded-sm p-2 border"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="font-semibold">{{ perm.resource }}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="link"
|
||||
type="button"
|
||||
@click="
|
||||
areAllActionsSelected(perm.resource)
|
||||
? clearAllActions(perm.resource)
|
||||
: selectAllActions(perm.resource)
|
||||
"
|
||||
>
|
||||
{{ areAllActionsSelected(perm.resource) ? 'Select None' : 'Select All' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex gap-4 flex-wrap">
|
||||
<label
|
||||
v-for="action in perm.actions"
|
||||
:key="action"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="
|
||||
!!newKeyPermissions.find(
|
||||
(p) => p.resource === perm.resource && p.actions.includes(action)
|
||||
)
|
||||
"
|
||||
@change="
|
||||
(e: Event) =>
|
||||
togglePermission(
|
||||
perm.resource,
|
||||
action,
|
||||
(e.target as HTMLInputElement)?.checked
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="text-sm">{{ action }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
<div v-if="error" class="text-red-500 mt-2 text-sm">
|
||||
</div>
|
||||
|
||||
<!-- Error display -->
|
||||
<div v-if="error" class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<p class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ extractGraphQLErrorMessage(error) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Permissions Preview -->
|
||||
<div class="mt-6 p-4 bg-muted/50 rounded-lg border border-muted">
|
||||
<EffectivePermissions
|
||||
:roles="formData.roles || []"
|
||||
:raw-permissions="formDataPermissions"
|
||||
:show-header="true"
|
||||
/>
|
||||
|
||||
<!-- Show selected roles for context -->
|
||||
<div
|
||||
v-if="formData.roles && formData.roles.length > 0"
|
||||
class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 mb-1">Selected Roles:</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="role in formData.roles"
|
||||
:key="role"
|
||||
class="px-2 py-1 bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-300 rounded text-xs"
|
||||
>
|
||||
{{ role }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Developer Tools Accordion (hide in authorization flow) -->
|
||||
<div v-if="!isAuthorizationMode" class="mt-4">
|
||||
<Accordion type="single" collapsible class="w-full">
|
||||
<AccordionItem value="developer-tools">
|
||||
<AccordionTrigger>
|
||||
<span class="text-sm font-semibold">Developer Tools</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div class="py-2">
|
||||
<DeveloperAuthorizationLink
|
||||
:roles="formData.roles || []"
|
||||
:raw-permissions="formDataPermissions"
|
||||
:app-name="formData.name || 'My Application'"
|
||||
:app-description="formData.description || 'API key for my application'"
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<!-- Success state for authorization mode -->
|
||||
<div
|
||||
v-if="isAuthorizationMode && createdKey && 'key' in createdKey"
|
||||
class="mt-4 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium">API Key created successfully!</span>
|
||||
<Button type="button" variant="ghost" size="sm" @click="copyApiKey">
|
||||
<ClipboardDocumentIcon class="w-4 h-4 mr-2" />
|
||||
{{ copied ? 'Copied!' : 'Copy Key' }}
|
||||
</Button>
|
||||
</div>
|
||||
<code class="block mt-2 p-2 bg-white dark:bg-gray-800 rounded text-xs break-all border">
|
||||
{{ createdKey.key }}
|
||||
</code>
|
||||
<p class="text-xs text-muted-foreground mt-2">Save this key securely for your application.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import { ref, watchEffect } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { useClipboardWithToast } from '~/composables/useClipboardWithToast';
|
||||
import type { AuthAction, ApiKeyFragment, Role } from '~/composables/gql/graphql';
|
||||
|
||||
import { ClipboardDocumentIcon, EyeIcon, EyeSlashIcon } from '@heroicons/vue/24/solid';
|
||||
import { ClipboardDocumentIcon, EyeIcon, EyeSlashIcon, ChevronDownIcon, LinkIcon } from '@heroicons/vue/24/solid';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@@ -13,6 +14,10 @@ import {
|
||||
Badge,
|
||||
Button,
|
||||
CardWrapper,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Input,
|
||||
PageContainer,
|
||||
Tooltip,
|
||||
@@ -22,30 +27,33 @@ import {
|
||||
} from '@unraid/ui';
|
||||
import { extractGraphQLErrorMessage } from '~/helpers/functions';
|
||||
|
||||
import type { ApiKeyFragment, ApiKeyWithKeyFragment } from '~/composables/gql/graphql';
|
||||
|
||||
import { useFragment } from '~/composables/gql/fragment-masking';
|
||||
import { useApiKeyStore } from '~/store/apiKey';
|
||||
import { API_KEY_FRAGMENT, DELETE_API_KEY, GET_API_KEY_META, GET_API_KEYS } from './apikey.query';
|
||||
import PermissionCounter from './PermissionCounter.vue';
|
||||
import EffectivePermissions from '~/components/ApiKey/EffectivePermissions.vue';
|
||||
import { generateScopes } from '~/utils/authorizationLink';
|
||||
|
||||
const { result, refetch } = useQuery(GET_API_KEYS);
|
||||
|
||||
const apiKeyStore = useApiKeyStore();
|
||||
const { createdKey } = storeToRefs(apiKeyStore);
|
||||
const apiKeys = ref<(ApiKeyFragment | ApiKeyWithKeyFragment)[]>([]);
|
||||
const apiKeys = ref<ApiKeyFragment[]>([]);
|
||||
|
||||
|
||||
watchEffect(() => {
|
||||
const baseKeys: (ApiKeyFragment | ApiKeyWithKeyFragment)[] =
|
||||
const baseKeys: ApiKeyFragment[] =
|
||||
result.value?.apiKeys.map((key) => useFragment(API_KEY_FRAGMENT, key)) || [];
|
||||
console.log(createdKey.value);
|
||||
|
||||
if (createdKey.value) {
|
||||
const existingKeyIndex = baseKeys.findIndex((key) => key.id === createdKey.value?.id);
|
||||
if (existingKeyIndex >= 0) {
|
||||
baseKeys[existingKeyIndex] = createdKey.value as ApiKeyFragment | ApiKeyWithKeyFragment;
|
||||
baseKeys[existingKeyIndex] = createdKey.value;
|
||||
} else {
|
||||
baseKeys.unshift(createdKey.value as ApiKeyFragment | ApiKeyWithKeyFragment);
|
||||
baseKeys.unshift(createdKey.value);
|
||||
}
|
||||
|
||||
// Don't automatically show keys - keep them hidden by default
|
||||
}
|
||||
|
||||
apiKeys.value = baseKeys;
|
||||
@@ -53,14 +61,23 @@ watchEffect(() => {
|
||||
|
||||
const metaQuery = useQuery(GET_API_KEY_META);
|
||||
const possibleRoles = ref<string[]>([]);
|
||||
const possiblePermissions = ref<{ resource: string; actions: string[] }[]>([]);
|
||||
const possiblePermissions = ref<{ resource: string; actions: AuthAction[] }[]>([]);
|
||||
watchEffect(() => {
|
||||
possibleRoles.value = metaQuery.result.value?.apiKeyPossibleRoles || [];
|
||||
possiblePermissions.value = metaQuery.result.value?.apiKeyPossiblePermissions || [];
|
||||
// Cast actions to AuthAction[] since GraphQL returns string[] but we know they're AuthAction values
|
||||
possiblePermissions.value = (metaQuery.result.value?.apiKeyPossiblePermissions || []).map(p => ({
|
||||
resource: p.resource,
|
||||
actions: p.actions as AuthAction[]
|
||||
}));
|
||||
});
|
||||
|
||||
const showKey = ref<Record<string, boolean>>({});
|
||||
const { copy, copied } = useClipboard();
|
||||
const { copyWithNotification, copied } = useClipboardWithToast();
|
||||
|
||||
// Template input state
|
||||
const showTemplateInput = ref(false);
|
||||
const templateUrl = ref('');
|
||||
const templateError = ref('');
|
||||
|
||||
const { mutate: deleteKey } = useMutation(DELETE_API_KEY);
|
||||
|
||||
@@ -70,11 +87,57 @@ function toggleShowKey(keyId: string) {
|
||||
showKey.value[keyId] = !showKey.value[keyId];
|
||||
}
|
||||
|
||||
function openCreateModal(key: ApiKeyFragment | ApiKeyWithKeyFragment | null = null) {
|
||||
function openCreateModal(key: ApiKeyFragment | ApiKeyFragment | null = null) {
|
||||
apiKeyStore.clearCreatedKey();
|
||||
apiKeyStore.showModal(key as ApiKeyFragment | null);
|
||||
}
|
||||
|
||||
function openCreateFromTemplate() {
|
||||
showTemplateInput.value = true;
|
||||
templateUrl.value = '';
|
||||
templateError.value = '';
|
||||
}
|
||||
|
||||
function cancelTemplateInput() {
|
||||
showTemplateInput.value = false;
|
||||
templateUrl.value = '';
|
||||
templateError.value = '';
|
||||
}
|
||||
|
||||
function applyTemplate() {
|
||||
templateError.value = '';
|
||||
|
||||
try {
|
||||
// Parse the template URL or query string
|
||||
let url: URL;
|
||||
|
||||
if (templateUrl.value.startsWith('http://') || templateUrl.value.startsWith('https://')) {
|
||||
// Full URL provided
|
||||
url = new URL(templateUrl.value);
|
||||
} else if (templateUrl.value.startsWith('?')) {
|
||||
// Query string only
|
||||
url = new URL(window.location.origin + templateUrl.value);
|
||||
} else {
|
||||
// Try to parse as query string without ?
|
||||
url = new URL(window.location.origin + '?' + templateUrl.value);
|
||||
}
|
||||
|
||||
// Extract query parameters
|
||||
const params = url.searchParams;
|
||||
|
||||
// Navigate to the authorization page with these params using window.location
|
||||
const authUrl = new URL('/Tools/ApiKeyAuthorize', window.location.origin);
|
||||
params.forEach((value, key) => {
|
||||
authUrl.searchParams.append(key, value);
|
||||
});
|
||||
window.location.href = authUrl.toString();
|
||||
|
||||
cancelTemplateInput();
|
||||
} catch (_err) {
|
||||
templateError.value = 'Invalid template URL or query string. Please check the format and try again.';
|
||||
}
|
||||
}
|
||||
|
||||
async function _deleteKey(_id: string) {
|
||||
if (!window.confirm('Are you sure you want to delete this API key? This action cannot be undone.'))
|
||||
return;
|
||||
@@ -87,13 +150,40 @@ async function _deleteKey(_id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function hasKey(key: ApiKeyFragment | ApiKeyWithKeyFragment): key is ApiKeyWithKeyFragment {
|
||||
return 'key' in key && !!key.key;
|
||||
async function copyKeyValue(keyValue: string) {
|
||||
await copyWithNotification(keyValue, 'API key copied to clipboard');
|
||||
}
|
||||
|
||||
async function copyKeyValue(keyValue: string) {
|
||||
await copy(keyValue);
|
||||
async function copyKeyTemplate(key: ApiKeyFragment) {
|
||||
try {
|
||||
// Generate scopes using the same logic as DeveloperAuthorizationLink
|
||||
const scopes = generateScopes(
|
||||
key.roles as Role[] || [],
|
||||
key.permissions?.map(p => ({
|
||||
resource: p.resource,
|
||||
actions: p.actions as AuthAction[]
|
||||
})) || []
|
||||
);
|
||||
|
||||
// Build URL parameters for the template
|
||||
const urlParams = new URLSearchParams({
|
||||
name: key.name,
|
||||
scopes: scopes.join(','),
|
||||
});
|
||||
|
||||
if (key.description) {
|
||||
urlParams.set('description', key.description);
|
||||
}
|
||||
|
||||
// Don't include redirect_uri for templates
|
||||
const templateQueryString = '?' + urlParams.toString();
|
||||
await copyWithNotification(templateQueryString, 'Template copied to clipboard');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy template:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -101,7 +191,22 @@ async function copyKeyValue(keyValue: string) {
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-2xl font-bold tracking-tight">API Keys</h2>
|
||||
<Button variant="primary" @click="openCreateModal(null)">Create API Key</Button>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="primary">
|
||||
Create API Key
|
||||
<ChevronDownIcon class="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem @click="openCreateModal(null)">
|
||||
Create New
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="openCreateFromTemplate">
|
||||
Create from Template
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuRoot>
|
||||
</div>
|
||||
<div
|
||||
v-if="deleteError"
|
||||
@@ -109,98 +214,124 @@ async function copyKeyValue(keyValue: string) {
|
||||
>
|
||||
{{ deleteError }}
|
||||
</div>
|
||||
<ul v-if="apiKeys.length" class="flex flex-col gap-4 mb-6">
|
||||
<CardWrapper v-for="key in apiKeys" :key="key.id">
|
||||
<li class="flex flex-row items-start justify-between gap-4 p-4 list-none">
|
||||
<div class="flex-1 min-w-0">
|
||||
<header class="flex gap-2 justify-between items-start">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm truncate"><b>ID:</b> {{ key.id.split(':')[1] }}</span>
|
||||
<span class="text-sm truncate"><b>Name:</b> {{ key.name }}</span>
|
||||
<span v-if="key.description" class="text-sm truncate"
|
||||
><b>Description:</b> {{ key.description }}</span
|
||||
>
|
||||
<div v-if="key.roles.length" class="flex flex-wrap gap-2 items-center">
|
||||
<span class="text-sm"><b>Roles:</b></span>
|
||||
<Badge v-for="role in key.roles" :key="role" variant="blue" size="xs">{{
|
||||
role
|
||||
}}</Badge>
|
||||
<div v-if="apiKeys.length" class="flex flex-col gap-4 mb-6">
|
||||
<div v-for="key in apiKeys" :key="key.id" class="w-full">
|
||||
<CardWrapper :padding="false">
|
||||
<div class="p-4 overflow-hidden">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm truncate max-w-[250px] md:max-w-md"><b>ID:</b> {{ key.id.split(':')[1] }}</div>
|
||||
<div class="text-sm"><b>Name:</b> {{ key.name }}</div>
|
||||
<div v-if="key.description" class="text-sm"
|
||||
><b>Description:</b> {{ key.description }}</div>
|
||||
<div v-if="key.roles.length" class="flex flex-wrap gap-2 items-center">
|
||||
<span class="text-sm"><b>Roles:</b></span>
|
||||
<Badge v-for="role in key.roles" :key="role" variant="blue" size="xs">{{
|
||||
role
|
||||
}}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-green-700 font-medium"><b>API Key:</b></span>
|
||||
<div class="relative flex-1 max-w-[300px]">
|
||||
<Input
|
||||
:model-value="showKey[key.id] ? key.key : '••••••••••••••••••••••••••••••••'"
|
||||
class="w-full font-mono text-xs px-2 py-1 rounded pr-10"
|
||||
readonly
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-2 flex items-center px-1 text-gray-500 hover:text-gray-700"
|
||||
tabindex="-1"
|
||||
@click="toggleShowKey(key.id)"
|
||||
>
|
||||
<component :is="showKey[key.id] ? EyeSlashIcon : EyeIcon" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip :delay-duration="0">
|
||||
<TooltipTrigger>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="copyKeyValue(key.key)">
|
||||
<ClipboardDocumentIcon class="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ copied ? 'Copied!' : 'Copy to clipboard...' }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
<Button variant="secondary" size="sm" @click="openCreateModal(key)">Edit</Button>
|
||||
<Button variant="destructive" size="sm" @click="_deleteKey(key.id)">Delete</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div v-if="key.permissions?.length" class="pt-2 w-full">
|
||||
<span class="text-sm"><b>Permissions:</b></span>
|
||||
<Accordion type="single" collapsible class="w-full">
|
||||
</div>
|
||||
<div v-if="key.permissions?.length || key.roles?.length" class="mt-4 pt-4 border-t">
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
class="w-full"
|
||||
>
|
||||
<AccordionItem :value="'permissions-' + key.id">
|
||||
<AccordionTrigger>
|
||||
<PermissionCounter
|
||||
:permissions="key.permissions"
|
||||
:possible-permissions="possiblePermissions"
|
||||
/>
|
||||
<span class="text-sm font-semibold">Effective Permissions</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div v-if="key.permissions?.length" class="flex flex-col gap-2 my-2">
|
||||
<div
|
||||
v-for="perm in key.permissions ?? []"
|
||||
:key="perm.resource"
|
||||
class="border rounded-sm p-2"
|
||||
>
|
||||
<div class="flex items-center gap-2 justify-between">
|
||||
<span class="font-semibold">{{ perm.resource }}</span>
|
||||
<PermissionCounter
|
||||
:permissions="[perm]"
|
||||
:possible-permissions="possiblePermissions"
|
||||
:hide-number="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-2 overflow-auto">
|
||||
<EffectivePermissions
|
||||
:roles="key.roles"
|
||||
:raw-permissions="key.permissions?.map(p => ({
|
||||
resource: p.resource,
|
||||
actions: p.actions
|
||||
})) || []"
|
||||
:show-header="false"
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<div v-if="hasKey(key)" class="mt-4 flex items-center gap-2">
|
||||
<span class="text-green-700 font-medium">API Key:</span>
|
||||
<div class="relative w-64">
|
||||
<Input
|
||||
:model-value="showKey[key.id] ? key.key : '••••••••••••••••••••••••••••••••'"
|
||||
class="w-full font-mono text-base px-2 py-1 rounded pr-10"
|
||||
readonly
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-2 flex items-center px-1 text-gray-500 hover:text-gray-700"
|
||||
tabindex="-1"
|
||||
@click="toggleShowKey(key.id)"
|
||||
>
|
||||
<component :is="showKey[key.id] ? EyeSlashIcon : EyeIcon" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t flex flex-wrap gap-2">
|
||||
<Button variant="secondary" size="sm" @click="openCreateModal(key)">Edit</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip :delay-duration="0">
|
||||
<TooltipTrigger>
|
||||
<Button variant="ghost" size="icon" @click="copyKeyValue(key.key)">
|
||||
<ClipboardDocumentIcon class="w-5 h-5" />
|
||||
<Button variant="outline" size="sm" @click="copyKeyTemplate(key)">
|
||||
<LinkIcon class="w-4 h-4 mr-1" />
|
||||
Copy Template
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ copied ? 'Copied!' : 'Copy to clipboard...' }}</p>
|
||||
<p>Copy a shareable template with these permissions</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button variant="destructive" size="sm" @click="_deleteKey(key.id)">Delete</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</CardWrapper>
|
||||
</ul>
|
||||
<ul v-else class="flex flex-col gap-4 mb-6">
|
||||
<li class="text-sm">No API keys found</li>
|
||||
</ul>
|
||||
</CardWrapper>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-4 mb-6">
|
||||
<p class="text-sm">No API keys found</p>
|
||||
</div>
|
||||
|
||||
<!-- Template Input Dialog -->
|
||||
<div v-if="showTemplateInput" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-background rounded-lg p-6 max-w-lg w-full mx-4">
|
||||
<h3 class="text-lg font-semibold mb-4">Create from Template</h3>
|
||||
<p class="text-sm text-muted-foreground mb-4">
|
||||
Paste a template URL or query string to pre-fill the API key creation form with permissions.
|
||||
</p>
|
||||
<Input
|
||||
v-model="templateUrl"
|
||||
placeholder="Paste template URL or query string (e.g., ?name=MyApp&scopes=role:admin)"
|
||||
class="mb-4"
|
||||
@keydown.enter="applyTemplate"
|
||||
/>
|
||||
<div v-if="templateError" class="mb-4 p-3 rounded border border-destructive bg-destructive/10 text-destructive text-sm">
|
||||
{{ templateError }}
|
||||
</div>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<Button variant="outline" @click="cancelTemplateInput">Cancel</Button>
|
||||
<Button variant="primary" @click="applyTemplate">Apply Template</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Button, Input, Switch } from '@unraid/ui';
|
||||
import { ClipboardDocumentIcon, LinkIcon } from '@heroicons/vue/24/outline';
|
||||
import { generateAuthorizationUrl } from '~/utils/authorizationLink';
|
||||
import { useClipboardWithToast } from '~/composables/useClipboardWithToast';
|
||||
import type { Role, AuthAction } from '~/composables/gql/graphql';
|
||||
|
||||
interface RawPermission {
|
||||
resource: string;
|
||||
actions: AuthAction[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
roles?: Role[];
|
||||
rawPermissions?: RawPermission[];
|
||||
appName?: string;
|
||||
appDescription?: string;
|
||||
redirectUrl?: string;
|
||||
show?: boolean;
|
||||
isAuthorizationMode?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
roles: () => [],
|
||||
rawPermissions: () => [],
|
||||
appName: 'CliInternal',
|
||||
appDescription: '',
|
||||
redirectUrl: '',
|
||||
show: true,
|
||||
isAuthorizationMode: false,
|
||||
});
|
||||
|
||||
// State for UI interactions
|
||||
const copySuccess = ref(false);
|
||||
const copyTemplateSuccess = ref(false);
|
||||
const showUrl = ref(false);
|
||||
const showTemplate = ref(false);
|
||||
const useCustomCallback = ref(false);
|
||||
const customCallbackUrl = ref('');
|
||||
|
||||
// Use clipboard composable
|
||||
const { copyWithNotification } = useClipboardWithToast();
|
||||
|
||||
// Reset custom callback URL when checkbox is unchecked
|
||||
watch(useCustomCallback, (newValue) => {
|
||||
if (!newValue) {
|
||||
customCallbackUrl.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Computed property for the effective redirect URL
|
||||
const effectiveRedirectUrl = computed(() => {
|
||||
if (useCustomCallback.value && customCallbackUrl.value) {
|
||||
return customCallbackUrl.value;
|
||||
}
|
||||
return props.redirectUrl;
|
||||
});
|
||||
|
||||
// Computed property for authorization URL
|
||||
const authorizationUrl = computed(() => {
|
||||
if (!props.show) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return generateAuthorizationUrl({
|
||||
appName: props.appName,
|
||||
appDescription: props.appDescription,
|
||||
roles: props.roles,
|
||||
rawPermissions: props.rawPermissions,
|
||||
redirectUrl: effectiveRedirectUrl.value,
|
||||
});
|
||||
});
|
||||
|
||||
// Computed property for template query string (without redirect_uri)
|
||||
const templateQueryString = computed(() => {
|
||||
if (!props.show) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Generate URL without redirect_uri for template sharing
|
||||
const url = generateAuthorizationUrl({
|
||||
appName: props.appName,
|
||||
appDescription: props.appDescription,
|
||||
roles: props.roles,
|
||||
rawPermissions: props.rawPermissions,
|
||||
redirectUrl: '', // Empty redirect URL for templates
|
||||
});
|
||||
|
||||
// Extract just the query string part
|
||||
const urlObj = new URL(url, window.location.origin);
|
||||
const params = new URLSearchParams(urlObj.search);
|
||||
params.delete('redirect_uri'); // Remove redirect_uri from template
|
||||
|
||||
return '?' + params.toString();
|
||||
});
|
||||
|
||||
// Check if there are any permissions to show
|
||||
const hasPermissions = computed(() => {
|
||||
return props.roles.length > 0 || props.rawPermissions.length > 0;
|
||||
});
|
||||
|
||||
// Function to copy authorization URL
|
||||
const handleCopy = async () => {
|
||||
const success = await copyWithNotification(
|
||||
authorizationUrl.value,
|
||||
'Authorization URL copied to clipboard'
|
||||
);
|
||||
|
||||
if (success) {
|
||||
copySuccess.value = true;
|
||||
setTimeout(() => {
|
||||
copySuccess.value = false;
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to toggle URL visibility
|
||||
const toggleShowUrl = () => {
|
||||
showUrl.value = !showUrl.value;
|
||||
showTemplate.value = false; // Hide template when showing URL
|
||||
};
|
||||
|
||||
// Function to toggle template visibility
|
||||
const toggleShowTemplate = () => {
|
||||
showTemplate.value = !showTemplate.value;
|
||||
showUrl.value = false; // Hide URL when showing template
|
||||
};
|
||||
|
||||
// Function to copy template query string
|
||||
const copyTemplate = async () => {
|
||||
const success = await copyWithNotification(
|
||||
templateQueryString.value,
|
||||
'Template copied to clipboard'
|
||||
);
|
||||
|
||||
if (success) {
|
||||
copyTemplateSuccess.value = true;
|
||||
setTimeout(() => {
|
||||
copyTemplateSuccess.value = false;
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="show" class="space-y-3">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium mb-2">Developer Authorization Link</h4>
|
||||
<div v-if="!hasPermissions" class="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg mb-3">
|
||||
<p class="text-sm text-amber-800 dark:text-amber-200">
|
||||
No permissions selected. Add roles or permissions above to generate an authorization link.
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" @click="toggleShowUrl">
|
||||
<LinkIcon class="w-4 h-4 mr-1" />
|
||||
{{ showUrl ? 'Hide' : 'Show' }} URL
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="handleCopy">
|
||||
<ClipboardDocumentIcon class="w-4 h-4 mr-1" />
|
||||
{{ copySuccess ? 'Copied!' : 'Copy URL' }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="toggleShowTemplate">
|
||||
<LinkIcon class="w-4 h-4 mr-1" />
|
||||
{{ showTemplate ? 'Hide' : 'Show' }} Template
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" @click="copyTemplate">
|
||||
<ClipboardDocumentIcon class="w-4 h-4 mr-1" />
|
||||
{{ copyTemplateSuccess ? 'Copied!' : 'Copy Template' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="hasPermissions" class="text-sm text-muted-foreground">
|
||||
Use this link to create an API key authorization for <strong>{{ appName }}</strong> with the selected permissions.
|
||||
Perfect for testing your app's OAuth-style API key flow.
|
||||
</p>
|
||||
|
||||
<div v-if="!isAuthorizationMode" class="flex items-center gap-2 mt-3">
|
||||
<Switch
|
||||
id="custom-callback"
|
||||
v-model="useCustomCallback"
|
||||
/>
|
||||
<label for="custom-callback" class="text-sm font-medium cursor-pointer">
|
||||
Use custom callback URL
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="!isAuthorizationMode && useCustomCallback" class="mt-2">
|
||||
<Input
|
||||
v-model="customCallbackUrl"
|
||||
type="url"
|
||||
placeholder="https://example.com/callback"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
Enter the URL where users will be redirected after authorization
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="showUrl" class="p-3 bg-secondary rounded border border-muted mt-3">
|
||||
<p class="text-xs text-muted-foreground mb-2">Full authorization URL with callback:</p>
|
||||
<code class="text-xs break-all text-foreground">
|
||||
{{ authorizationUrl }}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div v-if="showTemplate" class="p-3 bg-secondary rounded border border-muted mt-3">
|
||||
<p class="text-xs text-muted-foreground mb-2">Template query string (for sharing without callback):</p>
|
||||
<code class="text-xs break-all text-foreground">
|
||||
{{ templateQueryString }}
|
||||
</code>
|
||||
<p class="text-xs text-muted-foreground mt-2">
|
||||
This template can be used with "Create from Template" to pre-fill permissions without a callback URL.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
import { useLazyQuery } from '@vue/apollo-composable';
|
||||
import { Badge } from '@unraid/ui';
|
||||
import { PREVIEW_EFFECTIVE_PERMISSIONS } from './permissions-preview.query';
|
||||
import type { AuthAction, Role, PreviewEffectivePermissionsQuery } from '~/composables/gql/graphql';
|
||||
|
||||
interface RawPermission {
|
||||
resource: string;
|
||||
actions: AuthAction[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
roles?: Role[];
|
||||
rawPermissions?: RawPermission[];
|
||||
showHeader?: boolean;
|
||||
headerText?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
roles: () => [],
|
||||
rawPermissions: () => [],
|
||||
showHeader: true,
|
||||
headerText: 'Effective Permissions',
|
||||
});
|
||||
|
||||
// Query for effective permissions
|
||||
const { load: loadEffectivePermissions, loading, result } = useLazyQuery<PreviewEffectivePermissionsQuery>(PREVIEW_EFFECTIVE_PERMISSIONS);
|
||||
|
||||
// Computed property for effective permissions from the result
|
||||
const effectivePermissions = computed(() => {
|
||||
return result.value?.previewEffectivePermissions || [];
|
||||
});
|
||||
|
||||
// Format action for display - show the actual enum value or formatted string
|
||||
const formatAction = (action: string): string => {
|
||||
if (action === '*') return 'ALL ACTIONS';
|
||||
|
||||
// If it's already an enum value like CREATE_ANY, READ_ANY, show as-is
|
||||
if (action.includes('_')) {
|
||||
return action; // Keep the original enum format
|
||||
}
|
||||
|
||||
// If it's in scope format like 'create:any' or just 'create', format for display
|
||||
if (action.includes(':')) {
|
||||
return action.split(':')[0].toUpperCase() + ':' + action.split(':')[1].toUpperCase();
|
||||
}
|
||||
|
||||
// For simple verbs, uppercase them
|
||||
return action.toUpperCase();
|
||||
};
|
||||
|
||||
// Watch for changes to roles and permissions and reload
|
||||
watch(
|
||||
() => ({
|
||||
roles: props.roles,
|
||||
rawPermissions: props.rawPermissions,
|
||||
}),
|
||||
async ({ roles, rawPermissions }) => {
|
||||
// Skip if no roles or permissions
|
||||
if ((!roles || roles.length === 0) && (!rawPermissions || rawPermissions.length === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Transform permissions to the format expected by the query
|
||||
const permissions = rawPermissions?.map(perm => ({
|
||||
resource: perm.resource,
|
||||
actions: perm.actions
|
||||
})) || [];
|
||||
|
||||
// Call load with the parameters
|
||||
await loadEffectivePermissions(null, {
|
||||
roles: roles || [],
|
||||
permissions: permissions.length > 0 ? permissions : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load effective permissions:', error);
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<h3 v-if="showHeader" class="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
{{ headerText }}
|
||||
<span v-if="loading" class="text-xs text-muted-foreground">(loading...)</span>
|
||||
</h3>
|
||||
|
||||
<!-- Show effective permissions -->
|
||||
<div v-if="effectivePermissions.length > 0 && !loading" class="space-y-2">
|
||||
<div class="text-xs text-muted-foreground mb-2">
|
||||
These are the actual permissions that will be granted based on selected roles and custom permissions:
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<div
|
||||
v-for="perm in effectivePermissions"
|
||||
:key="perm.resource"
|
||||
class="text-xs bg-background p-2 rounded border border-muted"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-medium">{{ perm.resource }}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Badge
|
||||
v-for="action in perm.actions"
|
||||
:key="action"
|
||||
variant="green"
|
||||
size="xs"
|
||||
>
|
||||
{{ formatAction(action) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show loading state -->
|
||||
<div v-else-if="loading" class="text-xs text-muted-foreground">
|
||||
Loading permissions...
|
||||
</div>
|
||||
|
||||
<!-- Show message when no permissions selected -->
|
||||
<div v-else class="text-xs text-muted-foreground italic">
|
||||
No permissions selected yet
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,11 +4,12 @@ import { computed } from 'vue';
|
||||
import { Badge } from '@unraid/ui';
|
||||
|
||||
import { actionVariant } from './actionVariant.js';
|
||||
import type { AuthAction } from '~/composables/gql/graphql';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
permissions: { resource: string; actions: string[] }[];
|
||||
possiblePermissions?: { resource: string; actions: string[] }[];
|
||||
permissions: { resource: string; actions: AuthAction[] }[];
|
||||
possiblePermissions?: { resource: string; actions: AuthAction[] }[];
|
||||
hideNumber?: boolean;
|
||||
label?: string;
|
||||
}>(),
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { graphql } from '~/composables/gql';
|
||||
|
||||
export const GET_API_KEY_CREATION_FORM_SCHEMA = graphql(`
|
||||
query GetApiKeyCreationFormSchema {
|
||||
getApiKeyCreationFormSchema {
|
||||
id
|
||||
dataSchema
|
||||
uiSchema
|
||||
values
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -2,20 +2,6 @@ import { graphql } from '~/composables/gql/gql';
|
||||
|
||||
export const API_KEY_FRAGMENT = graphql(/* GraphQL */ `
|
||||
fragment ApiKey on ApiKey {
|
||||
id
|
||||
name
|
||||
description
|
||||
createdAt
|
||||
roles
|
||||
permissions {
|
||||
resource
|
||||
actions
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const API_KEY_FRAGMENT_WITH_KEY = graphql(/* GraphQL */ `
|
||||
fragment ApiKeyWithKey on ApiKeyWithSecret {
|
||||
id
|
||||
key
|
||||
name
|
||||
@@ -29,6 +15,8 @@ export const API_KEY_FRAGMENT_WITH_KEY = graphql(/* GraphQL */ `
|
||||
}
|
||||
`);
|
||||
|
||||
export const API_KEY_FRAGMENT_WITH_KEY = API_KEY_FRAGMENT;
|
||||
|
||||
export const GET_API_KEYS = graphql(/* GraphQL */ `
|
||||
query ApiKeys {
|
||||
apiKeys {
|
||||
@@ -41,7 +29,7 @@ export const CREATE_API_KEY = graphql(/* GraphQL */ `
|
||||
mutation CreateApiKey($input: CreateApiKeyInput!) {
|
||||
apiKey {
|
||||
create(input: $input) {
|
||||
...ApiKeyWithKey
|
||||
...ApiKey
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +39,7 @@ export const UPDATE_API_KEY = graphql(/* GraphQL */ `
|
||||
mutation UpdateApiKey($input: UpdateApiKeyInput!) {
|
||||
apiKey {
|
||||
update(input: $input) {
|
||||
...ApiKeyWithKey
|
||||
...ApiKey
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,3 +62,12 @@ export const GET_API_KEY_META = graphql(/* GraphQL */ `
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const PREVIEW_EFFECTIVE_PERMISSIONS = graphql(/* GraphQL */ `
|
||||
query PreviewEffectivePermissions($roles: [Role!], $permissions: [AddPermissionInput!]) {
|
||||
previewEffectivePermissions(roles: $roles, permissions: $permissions) {
|
||||
resource
|
||||
actions
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const PREVIEW_EFFECTIVE_PERMISSIONS = gql`
|
||||
query PreviewEffectivePermissions($roles: [Role!], $permissions: [AddPermissionInput!]) {
|
||||
previewEffectivePermissions(roles: $roles, permissions: $permissions) {
|
||||
resource
|
||||
actions
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_PERMISSIONS_FOR_ROLES = gql`
|
||||
query GetPermissionsForRoles($roles: [Role!]!) {
|
||||
getPermissionsForRoles(roles: $roles) {
|
||||
resource
|
||||
actions
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,299 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { Button, Input } from '@unraid/ui';
|
||||
import { useClipboardWithToast } from '~/composables/useClipboardWithToast.js';
|
||||
import { ClipboardDocumentIcon, EyeIcon, EyeSlashIcon } from '@heroicons/vue/24/outline';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useAuthorizationLink } from '~/composables/useAuthorizationLink.js';
|
||||
import { useApiKeyStore } from '~/store/apiKey.js';
|
||||
|
||||
// Use the composables for authorization logic
|
||||
const {
|
||||
authParams,
|
||||
hasValidRedirectUri,
|
||||
buildCallbackUrl,
|
||||
formData: authorizationFormData,
|
||||
displayAppName,
|
||||
hasPermissions,
|
||||
permissionsSummary,
|
||||
} = useAuthorizationLink();
|
||||
|
||||
// Use the API key store to control the global modal
|
||||
const apiKeyStore = useApiKeyStore();
|
||||
const { createdKey, modalVisible } = storeToRefs(apiKeyStore);
|
||||
|
||||
// Component state
|
||||
const showSuccess = ref(false);
|
||||
const createdApiKey = ref('');
|
||||
const error = ref('');
|
||||
const showKey = ref(false);
|
||||
|
||||
// Use clipboard for copying
|
||||
const { copyWithNotification, copied } = useClipboardWithToast();
|
||||
|
||||
// Watch for modal close to restore success view
|
||||
watch(modalVisible, (isVisible) => {
|
||||
if (!isVisible && createdKey.value && createdApiKey.value) {
|
||||
// Modal was closed, restore success view after editing
|
||||
showSuccess.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle key visibility
|
||||
const toggleShowKey = () => {
|
||||
showKey.value = !showKey.value;
|
||||
};
|
||||
|
||||
// Copy API key
|
||||
const copyApiKey = async () => {
|
||||
if (createdApiKey.value) {
|
||||
await copyWithNotification(createdApiKey.value, 'API key copied to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
// Open the authorization modal
|
||||
const openAuthorizationModal = () => {
|
||||
// Set up authorization parameters in the store
|
||||
apiKeyStore.setAuthorizationMode(
|
||||
authParams.value.name,
|
||||
authParams.value.description || `API key for ${displayAppName.value}`,
|
||||
authParams.value.scopes,
|
||||
handleAuthorize,
|
||||
authorizationFormData.value
|
||||
);
|
||||
|
||||
// Show the modal
|
||||
apiKeyStore.showModal();
|
||||
};
|
||||
|
||||
// Handle authorization success
|
||||
const handleAuthorize = (apiKey: string) => {
|
||||
createdApiKey.value = apiKey;
|
||||
showSuccess.value = true;
|
||||
apiKeyStore.hideModal();
|
||||
|
||||
// No automatic redirect - user must click the button
|
||||
};
|
||||
|
||||
// Open the edit modal for the created key
|
||||
const modifyApiKey = () => {
|
||||
if (createdKey.value) {
|
||||
// Open the modal in edit mode with the created key
|
||||
apiKeyStore.showModal(createdKey.value);
|
||||
// Don't clear states - the watchers will handle the flow
|
||||
}
|
||||
};
|
||||
|
||||
// Handle denial
|
||||
const deny = () => {
|
||||
if (hasValidRedirectUri.value) {
|
||||
try {
|
||||
const url = buildCallbackUrl(undefined, 'access_denied');
|
||||
window.location.href = url;
|
||||
} catch {
|
||||
window.location.href = '/';
|
||||
}
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
};
|
||||
|
||||
// Return to app with API key
|
||||
const returnToApp = () => {
|
||||
if (!hasValidRedirectUri.value || !createdApiKey.value) return;
|
||||
|
||||
try {
|
||||
const url = buildCallbackUrl(createdApiKey.value, undefined);
|
||||
window.location.href = url;
|
||||
} catch (_err) {
|
||||
error.value = 'Failed to redirect back to application';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full max-w-4xl mx-auto p-6">
|
||||
<!-- Success state -->
|
||||
<div v-if="showSuccess && createdApiKey" class="w-full bg-background rounded-lg shadow-sm border border-muted">
|
||||
<!-- Header -->
|
||||
<div class="p-6 pb-4 border-b border-muted">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-10 w-10 rounded-full bg-green-100 dark:bg-green-900/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">API Key Created Successfully</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Your API key for <strong>{{ displayAppName }}</strong> has been created
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6 space-y-4">
|
||||
<!-- API Key section -->
|
||||
<div>
|
||||
<label class="text-sm font-medium text-muted-foreground mb-2 block">Generated API Key</label>
|
||||
<div class="p-3 bg-secondary rounded-lg">
|
||||
<div class="flex gap-2 mb-2">
|
||||
<div class="relative flex-1">
|
||||
<Input
|
||||
:model-value="showKey ? createdApiKey : '••••••••••••••••••••••••••••••••'"
|
||||
class="font-mono text-sm pr-10 bg-background"
|
||||
readonly
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-y-0 right-2 flex items-center px-1 text-muted-foreground hover:text-foreground"
|
||||
@click="toggleShowKey"
|
||||
>
|
||||
<component :is="showKey ? EyeSlashIcon : EyeIcon" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@click="copyApiKey"
|
||||
>
|
||||
<ClipboardDocumentIcon class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ copied ? '✓ Copied to clipboard' : hasValidRedirectUri ? 'Save this key securely for your application.' : 'Save this key securely. You can now use it in your application.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Redirect info if available, or template info -->
|
||||
<div v-if="hasValidRedirectUri">
|
||||
<label class="text-sm font-medium text-muted-foreground mb-2 block">Next Step</label>
|
||||
<div class="p-3 bg-secondary rounded-lg">
|
||||
<p class="text-sm">
|
||||
Send this API key to complete the authorization
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
Destination: <code class="bg-background px-1.5 py-0.5 rounded">{{ authParams.redirectUri }}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<label class="text-sm font-medium text-muted-foreground mb-2 block">Template Applied</label>
|
||||
<div class="p-3 bg-secondary rounded-lg">
|
||||
<p class="text-sm">
|
||||
API key created from template with the configured permissions
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
You can manage this key from the API Keys settings page
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="p-6 pt-2 flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
@click="modifyApiKey"
|
||||
>
|
||||
Modify API Key
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasValidRedirectUri"
|
||||
variant="primary"
|
||||
class="flex-1"
|
||||
@click="returnToApp"
|
||||
>
|
||||
Send Key to {{ authParams.name }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Authorization form using ApiKeyCreate component -->
|
||||
<div v-else class="w-full bg-background rounded-lg shadow-sm border border-muted">
|
||||
<!-- Header -->
|
||||
<div class="p-6 pb-4 border-b border-muted">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-10 w-10 rounded-full bg-blue-100 dark:bg-blue-900/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">{{ hasValidRedirectUri ? 'API Key Authorization Request' : 'Create API Key from Template' }}</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<span v-if="hasValidRedirectUri">
|
||||
<strong>{{ displayAppName }}</strong> is requesting API access to your Unraid server
|
||||
</span>
|
||||
<span v-else>
|
||||
Create an API key for <strong>{{ displayAppName }}</strong> with pre-configured permissions
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6 space-y-4">
|
||||
<!-- Permissions section -->
|
||||
<div>
|
||||
<label class="text-sm font-medium text-muted-foreground mb-2 block">
|
||||
{{ hasValidRedirectUri ? 'Requested Permissions' : 'Template Permissions' }}
|
||||
</label>
|
||||
<div v-if="hasPermissions" class="p-3 bg-secondary rounded-lg">
|
||||
<p class="text-sm">{{ permissionsSummary }}</p>
|
||||
</div>
|
||||
<div v-else class="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<p class="text-sm text-amber-800 dark:text-amber-200">
|
||||
<span v-if="hasValidRedirectUri">
|
||||
No specific permissions requested. The application may be requesting basic access.
|
||||
</span>
|
||||
<span v-else>
|
||||
No specific permissions defined in this template.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Redirect info if available -->
|
||||
<div v-if="hasValidRedirectUri">
|
||||
<label class="text-sm font-medium text-muted-foreground mb-2 block">After Authorization</label>
|
||||
<div class="p-3 bg-secondary rounded-lg">
|
||||
<p class="text-sm">
|
||||
You will need to confirm and send the API key to the application
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
Destination: <code class="bg-background px-1.5 py-0.5 rounded">{{ authParams.redirectUri }}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="p-6 pt-2 flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
@click="deny"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
class="flex-1"
|
||||
@click="openAuthorizationModal"
|
||||
>
|
||||
{{ hasValidRedirectUri ? 'Review Permissions & Authorize' : 'Review Permissions' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<div v-if="error" class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p class="text-red-800 dark:text-red-200">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { reactive, watch } from 'vue';
|
||||
|
||||
import { Input, Label, Select, Switch } from '@unraid/ui';
|
||||
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent, Input, Label, Select, Switch } from '@unraid/ui';
|
||||
import { defaultColors } from '~/themes/default';
|
||||
|
||||
import type { Theme } from '~/themes/types';
|
||||
@@ -49,28 +49,35 @@ const items = [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 border-solid border-2 p-2 border-r-2">
|
||||
<h1 class="text-lg">Color Theme Customization</h1>
|
||||
<Accordion>
|
||||
<AccordionItem value="color-theme-customization">
|
||||
<AccordionTrigger>Color Theme Customization</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div class="flex flex-col gap-2 border-solid border-2 p-2 border-r-2">
|
||||
<h1 class="text-lg">Color Theme Customization</h1>
|
||||
|
||||
<Label for="theme-select">Theme</Label>
|
||||
<Select v-model="form.selectedTheme" :items="items" placeholder="Select a theme" />
|
||||
<Label for="theme-select">Theme</Label>
|
||||
<Select v-model="form.selectedTheme" :items="items" placeholder="Select a theme" />
|
||||
|
||||
<Label for="primary-text-color">Header Primary Text Color</Label>
|
||||
<Input id="primary-text-color" v-model="form.textPrimary" />
|
||||
<Label for="primary-text-color">Header Primary Text Color</Label>
|
||||
<Input id="primary-text-color" v-model="form.textPrimary" />
|
||||
|
||||
<Label for="secondary-text-color">Header Secondary Text Color</Label>
|
||||
<Input id="secondary-text-color" v-model="form.textSecondary" />
|
||||
<Label for="secondary-text-color">Header Secondary Text Color</Label>
|
||||
<Input id="secondary-text-color" v-model="form.textSecondary" />
|
||||
|
||||
<Label for="background-color">Header Background Color</Label>
|
||||
<Input id="background-color" v-model="form.bgColor" />
|
||||
<Label for="background-color">Header Background Color</Label>
|
||||
<Input id="background-color" v-model="form.bgColor" />
|
||||
|
||||
<Label for="gradient">Gradient</Label>
|
||||
<Switch id="gradient" v-model:checked="form.gradient" />
|
||||
<Label for="gradient">Gradient</Label>
|
||||
<Switch id="gradient" v-model:checked="form.gradient" />
|
||||
|
||||
<Label for="description">Description</Label>
|
||||
<Switch id="description" v-model:checked="form.description" />
|
||||
<Label for="description">Description</Label>
|
||||
<Switch id="description" v-model:checked="form.description" />
|
||||
|
||||
<Label for="banner">Banner</Label>
|
||||
<Switch id="banner" v-model:checked="form.banner" />
|
||||
</div>
|
||||
<Label for="banner">Banner</Label>
|
||||
<Switch id="banner" v-model:checked="form.banner" />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</template>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user