Skip to content

Commit

Permalink
feat: Add scopes to /login endpoint (no-changelog) (#7718)
Browse files Browse the repository at this point in the history
Github issue / Community forum post (link here to close automatically):
  • Loading branch information
valya authored Nov 16, 2023
1 parent ebee1a5 commit d39bb25
Show file tree
Hide file tree
Showing 9 changed files with 158 additions and 15 deletions.
5 changes: 4 additions & 1 deletion packages/@n8n/permissions/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export type DefaultOperations = 'create' | 'read' | 'update' | 'delete' | 'list';
export type Resource =
| 'workflow'
| 'tag'
| 'user'
| 'credential'
| 'variable'
Expand All @@ -13,7 +14,8 @@ export type ResourceScope<
> = `${R}:${Operations}`;
export type WildcardScope = `${Resource}:*` | '*';

export type WorkflowScope = ResourceScope<'workflow'>;
export type WorkflowScope = ResourceScope<'workflow', DefaultOperations | 'share'>;
export type TagScope = ResourceScope<'tag'>;
export type UserScope = ResourceScope<'user'>;
export type CredentialScope = ResourceScope<'credential'>;
export type VariableScope = ResourceScope<'variable'>;
Expand All @@ -25,6 +27,7 @@ export type ExternalSecretStoreScope = ResourceScope<

export type Scope =
| WorkflowScope
| TagScope
| UserScope
| CredentialScope
| VariableScope
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
},
"dependencies": {
"@n8n/client-oauth2": "workspace:*",
"@n8n/permissions": "workspace:*",
"@n8n_io/license-sdk": "~2.7.1",
"@oclif/command": "^1.8.16",
"@oclif/config": "^1.18.17",
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/Interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import type { WorkflowRepository } from '@db/repositories/workflow.repository';
import type { LICENSE_FEATURES, LICENSE_QUOTAS } from './constants';
import type { WorkflowWithSharingsAndCredentials } from './workflows/workflows.types';
import type { WorkerJobStatusSummary } from './services/orchestration/worker/types';
import type { Scope } from '@n8n/permissions';

export interface ICredentialsTypeData {
[key: string]: CredentialLoadingDetails;
Expand Down Expand Up @@ -772,6 +773,7 @@ export interface PublicUser {
isPending: boolean;
hasRecoveryCodesLeft: boolean;
globalRole?: Role;
globalScopes?: Scope[];
signInType: AuthProviderType;
disabled: boolean;
settings?: IUserSettings | null;
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export class AuthController {
authenticationMethod: usedAuthenticationMethod,
});

return this.userService.toPublic(user, { posthog: this.postHog });
return this.userService.toPublic(user, { posthog: this.postHog, withScopes: true });
}
void this.internalHooks.onUserLoginFailed({
user: email,
Expand All @@ -129,7 +129,7 @@ export class AuthController {
try {
user = await resolveJwt(cookieContents);

return await this.userService.toPublic(user, { posthog: this.postHog });
return await this.userService.toPublic(user, { posthog: this.postHog, withScopes: true });
} catch (error) {
res.clearCookie(AUTH_COOKIE_NAME);
}
Expand All @@ -152,7 +152,7 @@ export class AuthController {
}

await issueCookie(res, user);
return this.userService.toPublic(user, { posthog: this.postHog });
return this.userService.toPublic(user, { posthog: this.postHog, withScopes: true });
}

/**
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/src/databases/entities/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,18 @@ import { objectRetriever, lowerCaser } from '../utils/transformers';
import { WithTimestamps, jsonColumnType } from './AbstractEntity';
import type { IPersonalizationSurveyAnswers } from '@/Interfaces';
import type { AuthIdentity } from './AuthIdentity';
import { ownerPermissions, memberPermissions } from '@/permissions/roles';
import { hasScope, type HasScopeOptions, type Scope } from '@n8n/permissions';

export const MIN_PASSWORD_LENGTH = 8;

export const MAX_PASSWORD_LENGTH = 64;

const STATIC_SCOPE_MAP: Record<string, Scope[]> = {
owner: ownerPermissions,
member: memberPermissions,
};

@Entity()
export class User extends WithTimestamps implements IUser {
@PrimaryGeneratedColumn('uuid')
Expand Down Expand Up @@ -125,4 +132,21 @@ export class User extends WithTimestamps implements IUser {
computeIsOwner(): void {
this.isOwner = this.globalRole?.name === 'owner';
}

get globalScopes() {
return STATIC_SCOPE_MAP[this.globalRole?.name] ?? [];
}

async hasGlobalScope(
scope: Scope | Scope[],
hasScopeOptions?: HasScopeOptions,
): Promise<boolean> {
return hasScope(
scope,
{
global: this.globalScopes,
},
hasScopeOptions,
);
}
}
49 changes: 49 additions & 0 deletions packages/cli/src/permissions/roles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Scope } from '@n8n/permissions';

export const ownerPermissions: Scope[] = [
'workflow:create',
'workflow:read',
'workflow:update',
'workflow:delete',
'workflow:list',
'workflow:share',
'user:create',
'user:read',
'user:update',
'user:delete',
'user:list',
'credential:create',
'credential:read',
'credential:update',
'credential:delete',
'credential:list',
'variable:create',
'variable:read',
'variable:update',
'variable:delete',
'variable:list',
'sourceControl:pull',
'sourceControl:push',
'sourceControl:manage',
'externalSecretsStore:create',
'externalSecretsStore:read',
'externalSecretsStore:update',
'externalSecretsStore:delete',
'externalSecretsStore:list',
'externalSecretsStore:refresh',
'tag:create',
'tag:read',
'tag:update',
'tag:delete',
'tag:list',
];
export const adminPermissions: Scope[] = ownerPermissions.concat();
export const memberPermissions: Scope[] = [
'user:list',
'variable:list',
'variable:read',
'tag:create',
'tag:read',
'tag:update',
'tag:list',
];
9 changes: 8 additions & 1 deletion packages/cli/src/services/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,10 @@ export class UserService {
return user;
}

async toPublic(user: User, options?: { withInviteUrl?: boolean; posthog?: PostHogClient }) {
async toPublic(
user: User,
options?: { withInviteUrl?: boolean; posthog?: PostHogClient; withScopes?: boolean },
) {
const { password, updatedAt, apiKey, authIdentities, ...rest } = user;

const ldapIdentity = authIdentities?.find((i) => i.providerType === 'ldap');
Expand All @@ -124,6 +127,10 @@ export class UserService {
hasRecoveryCodesLeft: !!user.mfaRecoveryCodes?.length,
};

if (options?.withScopes) {
publicUser.globalScopes = user.globalScopes;
}

if (options?.withInviteUrl && publicUser.isPending) {
publicUser = this.addInviteUrl(publicUser, user.id);
}
Expand Down
74 changes: 64 additions & 10 deletions packages/cli/test/integration/auth.api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,17 @@ describe('POST /login', () => {

expect(response.statusCode).toBe(200);

const { id, email, firstName, lastName, password, personalizationAnswers, globalRole, apiKey } =
response.body.data;
const {
id,
email,
firstName,
lastName,
password,
personalizationAnswers,
globalRole,
apiKey,
globalScopes,
} = response.body.data;

expect(validator.isUUID(id)).toBe(true);
expect(email).toBe(owner.email);
Expand All @@ -63,6 +72,7 @@ describe('POST /login', () => {
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
expect(apiKey).toBeUndefined();
expect(globalScopes).toBeDefined();

const authToken = utils.getAuthToken(response);
expect(authToken).toBeDefined();
Expand Down Expand Up @@ -135,8 +145,17 @@ describe('GET /login', () => {

expect(response.statusCode).toBe(200);

const { id, email, firstName, lastName, password, personalizationAnswers, globalRole, apiKey } =
response.body.data;
const {
id,
email,
firstName,
lastName,
password,
personalizationAnswers,
globalRole,
apiKey,
globalScopes,
} = response.body.data;

expect(validator.isUUID(id)).toBe(true);
expect(email).toBeDefined();
Expand All @@ -149,6 +168,8 @@ describe('GET /login', () => {
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
expect(apiKey).toBeUndefined();
expect(globalScopes).toBeDefined();
expect(globalScopes).toContain('workflow:read');

const authToken = utils.getAuthToken(response);
expect(authToken).toBeUndefined();
Expand All @@ -161,8 +182,17 @@ describe('GET /login', () => {

expect(response.statusCode).toBe(200);

const { id, email, firstName, lastName, password, personalizationAnswers, globalRole, apiKey } =
response.body.data;
const {
id,
email,
firstName,
lastName,
password,
personalizationAnswers,
globalRole,
apiKey,
globalScopes,
} = response.body.data;

expect(validator.isUUID(id)).toBe(true);
expect(email).toBeDefined();
Expand All @@ -175,6 +205,8 @@ describe('GET /login', () => {
expect(globalRole.name).toBe('member');
expect(globalRole.scope).toBe('global');
expect(apiKey).toBeUndefined();
expect(globalScopes).toBeDefined();
expect(globalScopes).not.toContain('workflow:read');

const authToken = utils.getAuthToken(response);
expect(authToken).toBeUndefined();
Expand All @@ -187,8 +219,17 @@ describe('GET /login', () => {

expect(response.statusCode).toBe(200);

const { id, email, firstName, lastName, password, personalizationAnswers, globalRole, apiKey } =
response.body.data;
const {
id,
email,
firstName,
lastName,
password,
personalizationAnswers,
globalRole,
apiKey,
globalScopes,
} = response.body.data;

expect(validator.isUUID(id)).toBe(true);
expect(email).toBe(owner.email);
Expand All @@ -201,6 +242,8 @@ describe('GET /login', () => {
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
expect(apiKey).toBeUndefined();
expect(globalScopes).toBeDefined();
expect(globalScopes).toContain('workflow:read');

const authToken = utils.getAuthToken(response);
expect(authToken).toBeUndefined();
Expand All @@ -213,8 +256,17 @@ describe('GET /login', () => {

expect(response.statusCode).toBe(200);

const { id, email, firstName, lastName, password, personalizationAnswers, globalRole, apiKey } =
response.body.data;
const {
id,
email,
firstName,
lastName,
password,
personalizationAnswers,
globalRole,
apiKey,
globalScopes,
} = response.body.data;

expect(validator.isUUID(id)).toBe(true);
expect(email).toBe(member.email);
Expand All @@ -227,6 +279,8 @@ describe('GET /login', () => {
expect(globalRole.name).toBe('member');
expect(globalRole.scope).toBe('global');
expect(apiKey).toBeUndefined();
expect(globalScopes).toBeDefined();
expect(globalScopes).not.toContain('workflow:read');

const authToken = utils.getAuthToken(response);
expect(authToken).toBeUndefined();
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit d39bb25

Please sign in to comment.