0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(schemas,cli,core,cloud): add manage tenant self scope (#3865)

This commit is contained in:
Darcy Ye 2023-05-26 17:38:09 +08:00 committed by GitHub
parent 7fb5374963
commit 8cbf87bb73
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 292 additions and 82 deletions

View file

@ -54,7 +54,7 @@ jobs:
strategy:
fail-fast: false
matrix:
target: [api, ui, ui-cloud]
target: [api, api-cloud, ui, ui-cloud]
needs: package
runs-on: ubuntu-latest

View file

@ -33,10 +33,12 @@ export const seedAdminData = async (
data: AdminData | UpdateAdminData,
...additionalScopes: CreateScope[]
) => {
const { resource, scope, role } = data;
const { resource, scopes, role } = data;
assert(
resource.tenantId === scope.tenantId && scope.tenantId === role.tenantId,
scopes.every(
(scope) => resource.tenantId === scope.tenantId && scope.tenantId === role.tenantId
),
new Error('All data should have the same tenant ID')
);
@ -58,19 +60,24 @@ export const seedAdminData = async (
};
await pool.query(insertInto(resource, 'resources'));
await pool.query(insertInto(scope, 'scopes'));
await Promise.all(additionalScopes.map(async (scope) => pool.query(insertInto(scope, 'scopes'))));
await Promise.all(
[...scopes, ...additionalScopes].map(async (scope) => pool.query(insertInto(scope, 'scopes')))
);
const roleId = await processRole();
await pool.query(
insertInto(
{
id: generateStandardId(),
roleId,
scopeId: scope.id,
tenantId: resource.tenantId,
} satisfies CreateRolesScope,
'roles_scopes'
await Promise.all(
scopes.map(async ({ id }) =>
pool.query(
insertInto(
{
id: generateStandardId(),
roleId,
scopeId: id,
tenantId: resource.tenantId,
} satisfies CreateRolesScope,
'roles_scopes'
)
)
)
);
};

View file

@ -44,30 +44,38 @@ export const createTenantsQueries = (client: Queryable<PostgreSql>) => {
`);
const insertAdminData = async (data: AdminData) => {
const { resource, scope, role } = data;
const { resource, scopes, role } = data;
assert(
resource.tenantId && scope.tenantId && role.tenantId,
resource.tenantId && scopes.every(({ tenantId }) => tenantId) && role.tenantId,
new Error('Tenant ID cannot be empty.')
);
assert(
resource.tenantId === scope.tenantId && scope.tenantId === role.tenantId,
scopes.every(
(scope) => resource.tenantId === scope.tenantId && scope.tenantId === role.tenantId
),
new Error('All data should have the same tenant ID.')
);
await client.query(insertInto(resource, 'resources'));
await client.query(insertInto(scope, 'scopes'));
await Promise.all(scopes.map(async (scope) => client.query(insertInto(scope, 'scopes'))));
await client.query(insertInto(role, 'roles'));
await client.query(
insertInto(
{
id: generateStandardId(),
roleId: role.id,
scopeId: scope.id,
tenantId: resource.tenantId,
} satisfies CreateRolesScope,
'roles_scopes'
const { tenantId } = resource;
await Promise.all(
scopes.map(async ({ id }) =>
client.query(
insertInto(
{
id: generateStandardId(),
roleId: role.id,
scopeId: id,
tenantId,
} satisfies CreateRolesScope,
'roles_scopes'
)
)
)
);
};

View file

@ -55,7 +55,8 @@ function Content() {
PredefinedScope.All,
...conditionalArray(
isCloud && cloudApi.scopes.CreateTenant,
isCloud && cloudApi.scopes.ManageTenant
isCloud && cloudApi.scopes.ManageTenant,
isCloud && cloudApi.scopes.ManageTenantSelf
),
],
[]

View file

@ -20,9 +20,12 @@ mockEsm('./utils.js', () => ({
const { jwtVerify } = mockEsm('jose', () => ({
createLocalJWKSet: jest.fn(),
jwtVerify: jest
.fn()
.mockReturnValue({ payload: { sub: 'fooUser', scope: defaultManagementApi.scope.name } }),
jwtVerify: jest.fn().mockReturnValue({
payload: {
sub: 'fooUser',
scope: defaultManagementApi.scopes.map((scope) => scope.name).join(' '),
},
}),
}));
const audience = defaultManagementApi.resource.indicator;

View file

@ -60,7 +60,11 @@ export const verifyBearerTokenFromRequest = async (
if ((!isProduction || isIntegrationTest) && userId) {
consoleLog.warn(`Found dev user ID ${userId}, skip token validation.`);
return { sub: userId, clientId: undefined, scopes: [defaultManagementApi.scope.name] };
return {
sub: userId,
clientId: undefined,
scopes: defaultManagementApi.scopes.map(({ name }) => name),
};
}
const getKeysAndIssuer = async (): Promise<[JWK[], string[]]> => {

View file

@ -12,8 +12,9 @@
"scripts": {
"build": "rm -rf lib/ && tsc -p tsconfig.test.json --sourcemap",
"test:only": "NODE_OPTIONS=--experimental-vm-modules jest",
"test": "pnpm build && pnpm test:api && pnpm test:ui",
"test": "pnpm build && pnpm test:api && pnpm test:api-cloud && pnpm test:ui",
"test:api": "pnpm test:only -i ./lib/tests/api/",
"test:api-cloud": "pnpm test:only -i ./lib/tests/api-cloud/",
"test:ui": "pnpm test:only -i --config=jest.config.ui.js ./lib/tests/ui/",
"test:ui-cloud": "pnpm test:only -i --config=jest.config.ui.js ./lib/tests/ui-cloud/",
"lint": "eslint --ext .ts src",

View file

@ -1,6 +1,6 @@
import { got } from 'got';
import { logtoConsoleUrl, logtoUrl } from '#src/constants.js';
import { logtoConsoleUrl, logtoUrl, logtoCloudUrl } from '#src/constants.js';
const api = got.extend({
prefixUrl: new URL('/api', logtoUrl),
@ -24,3 +24,7 @@ export const authedAdminTenantApi = adminTenantApi.extend({
'development-user-id': 'integration-test-admin-user',
},
});
export const cloudApi = got.extend({
prefixUrl: new URL('/api', logtoCloudUrl),
});

View file

@ -0,0 +1,21 @@
import type { TenantInfo } from '@logto/schemas';
import { cloudApi } from './api.js';
export const createTenant = async (accessToken: string) => {
return cloudApi
.extend({
headers: { authorization: `Bearer ${accessToken}` },
})
.post('tenants')
.json<TenantInfo>();
};
export const getTenants = async (accessToken: string) => {
return cloudApi
.extend({
headers: { authorization: `Bearer ${accessToken}` },
})
.get('tenants')
.json<TenantInfo[]>();
};

View file

@ -2,15 +2,21 @@
// Since they are just different in URLs
import type { LogtoConfig } from '@logto/node';
import type { Role, User } from '@logto/schemas';
import {
cloudApiIndicator,
CloudScope,
PredefinedScope,
adminTenantId,
defaultTenantId,
getManagementApiResourceIndicator,
getManagementApiAdminName,
adminConsoleApplicationId,
InteractionEvent,
AdminTenantRole,
type Role,
type User,
} from '@logto/schemas';
import { conditionalArray } from '@silverhand/essentials';
import { authedAdminTenantApi as api, adminTenantApi } from '#src/api/api.js';
import type { InteractionPayload } from '#src/api/interaction.js';
@ -25,7 +31,7 @@ export const createResponseWithCode = (statusCode: number) => ({
response: { statusCode },
});
export const createUserWithAllRoles = async () => {
const createUserWithRoles = async (roleNames: string[]) => {
const username = generateUsername();
const password = generatePassword();
const user = await api
@ -37,16 +43,24 @@ export const createUserWithAllRoles = async () => {
// Should have roles for default tenant Management API and admin tenant Me API
const roles = await api.get('roles').json<Role[]>();
await Promise.all(
roles.map(async ({ id }) =>
api.post(`roles/${id}/users`, {
json: { userIds: [user.id] },
})
)
roles
.filter(({ name }) => roleNames.includes(name))
.map(async ({ id }) =>
api.post(`roles/${id}/users`, {
json: { userIds: [user.id] },
})
)
);
return [user, { username, password }] as const;
};
export const createUserWithAllRoles = async () => {
const allRoles = await api.get('roles').json<Role[]>();
const allRoleNames = allRoles.map(({ name }) => name);
return createUserWithRoles(allRoleNames);
};
export const deleteUser = async (id: string) => {
await api.delete(`users/${id}`);
};
@ -86,7 +100,7 @@ export const initClientAndSignIn = async (
return client;
};
export const createUserAndSignInWithClient = async () => {
export const createUserWithAllRolesAndSignInToClient = async () => {
const [{ id }, { username, password }] = await createUserWithAllRoles();
const client = await initClientAndSignIn(username, password, {
resources: [resourceDefault, resourceMe],
@ -95,3 +109,20 @@ export const createUserAndSignInWithClient = async () => {
return { id, client };
};
export const createUserAndSignInToCloudClient = async (
userRoleType: AdminTenantRole.User | AdminTenantRole.Admin
) => {
const [{ id }, { username, password }] = await createUserWithRoles(
conditionalArray<string>(
AdminTenantRole.User,
userRoleType === AdminTenantRole.Admin && getManagementApiAdminName(adminTenantId)
)
);
const client = await initClientAndSignIn(username, password, {
resources: [cloudApiIndicator],
scopes: Object.values(CloudScope),
});
return { id, client };
};

View file

@ -0,0 +1,56 @@
import {
cloudApiIndicator,
CloudScope,
AdminTenantRole,
type Resource,
type Scope,
type Role,
} from '@logto/schemas';
import { authedAdminTenantApi } from '#src/api/api.js';
import { createTenant, getTenants } from '#src/api/tenant.js';
import { createUserAndSignInToCloudClient } from '#src/helpers/admin-tenant.js';
describe('Tenant APIs', () => {
it('should be able to create multiple tenants for `admin` role', async () => {
const { client } = await createUserAndSignInToCloudClient(AdminTenantRole.Admin);
const accessToken = await client.getAccessToken(cloudApiIndicator);
const tenant1 = await createTenant(accessToken);
const tenant2 = await createTenant(accessToken);
expect(tenant1).toHaveProperty('id');
expect(tenant2).toHaveProperty('id');
const tenants = await getTenants(accessToken);
expect(tenants.length).toBeGreaterThan(2);
expect(tenants.find((tenant) => tenant.id === tenant1.id)).toBeDefined();
expect(tenants.find((tenant) => tenant.id === tenant2.id)).toBeDefined();
});
it('should create only one tenant for `user` role', async () => {
const { client } = await createUserAndSignInToCloudClient(AdminTenantRole.User);
const accessToken = await client.getAccessToken(cloudApiIndicator);
const tenant1 = await createTenant(accessToken);
await expect(createTenant(accessToken)).rejects.toThrow();
expect(tenant1).toHaveProperty('id');
const tenants = await getTenants(accessToken);
expect(tenants.length).toEqual(1);
expect(tenants.find((tenant) => tenant.id === tenant1.id)).toBeDefined();
});
it('`user` role should have `CloudScope.ManageTenantSelf` scope', async () => {
const resources = await authedAdminTenantApi.get('resources').json<Resource[]>();
const cloudApiResource = resources.find(({ indicator }) => indicator === cloudApiIndicator);
expect(cloudApiResource).toBeDefined();
const scopes = await authedAdminTenantApi
.get(`resources/${cloudApiResource!.id}/scopes`)
.json<Scope[]>();
const manageOwnTenantScope = scopes.find((scope) => scope.name === CloudScope.ManageTenantSelf);
expect(manageOwnTenantScope).toBeDefined();
const roles = await authedAdminTenantApi.get('roles').json<Role[]>();
const userRole = roles.find(({ name }) => name === 'user');
expect(userRole).toBeDefined();
const roleScopes = await authedAdminTenantApi
.get(`roles/${userRole!.id}/scopes`)
.json<Scope[]>();
expect(roleScopes.find(({ id }) => id === manageOwnTenantScope!.id)).toBeDefined();
});
});

View file

@ -3,7 +3,7 @@ import { got } from 'got';
import { logtoConsoleUrl, logtoUrl } from '#src/constants.js';
import {
createResponseWithCode,
createUserAndSignInWithClient,
createUserWithAllRolesAndSignInToClient,
deleteUser,
resourceDefault,
resourceMe,
@ -22,7 +22,7 @@ describe('me', () => {
});
it('should only recognize the access token with correct resource and scope', async () => {
const { id, client } = await createUserAndSignInWithClient();
const { id, client } = await createUserWithAllRolesAndSignInToClient();
await expect(
got.get(logtoConsoleUrl + '/me/custom-data', {
@ -40,7 +40,7 @@ describe('me', () => {
});
it('should be able to update custom data', async () => {
const { id, client } = await createUserAndSignInWithClient();
const { id, client } = await createUserWithAllRolesAndSignInToClient();
const headers = { authorization: `Bearer ${await client.getAccessToken(resourceMe)}` };
const data = await got

View file

@ -29,7 +29,7 @@ describe('get access token', () => {
it('can sign in and getAccessToken with admin user', async () => {
const client = new MockClient({
resources: [defaultManagementApi.resource.indicator],
scopes: [defaultManagementApi.scope.name],
scopes: defaultManagementApi.scopes.map(({ name }) => name),
});
await client.initSession();
await client.successSend(putInteraction, {
@ -42,7 +42,7 @@ describe('get access token', () => {
expect(accessToken).not.toBeNull();
expect(getAccessTokenPayload(accessToken)).toHaveProperty(
'scope',
defaultManagementApi.scope.name
defaultManagementApi.scopes.map(({ name }) => name).join(' ')
);
// Request for invalid resource should throw
@ -52,7 +52,7 @@ describe('get access token', () => {
it('can sign in and getAccessToken with guest user', async () => {
const client = new MockClient({
resources: [defaultManagementApi.resource.indicator],
scopes: [defaultManagementApi.scope.name],
scopes: defaultManagementApi.scopes.map(({ name }) => name),
});
await client.initSession();
await client.successSend(putInteraction, {
@ -65,7 +65,7 @@ describe('get access token', () => {
expect(getAccessTokenPayload(accessToken)).not.toHaveProperty(
'scope',
defaultManagementApi.scope.name
defaultManagementApi.scopes.map(({ name }) => name).join(' ')
);
});

View file

@ -9,7 +9,7 @@ describe('scopes', () => {
it('should get management api resource scopes successfully', async () => {
const scopes = await getScopes(defaultManagementApi.resource.id);
expect(scopes[0]).toMatchObject(defaultManagementApi.scope);
expect(scopes[0]).toMatchObject(expect.objectContaining(defaultManagementApi.scopes[0]));
});
it('should create scope successfully', async () => {

View file

@ -0,0 +1,56 @@
import { generateStandardId } from '@logto/shared/universal';
import { sql } from 'slonik';
import type { AlterationScript } from '../lib/types/alteration.js';
const adminTenantId = 'admin';
const alteration: AlterationScript = {
up: async (pool) => {
// Get `resourceId` of the admin tenant's resource whose indicator is `https://cloud.logto.io/api`.
const { id: resourceId } = await pool.one<{ id: string }>(sql`
select id from resources
where tenant_id = ${adminTenantId}
and indicator = 'https://cloud.logto.io/api'
`);
// Get `roleId` of the admin tenant's role whose name is `user`.
const { id: roleId } = await pool.one<{ id: string }>(sql`
select id from roles
where tenant_id = ${adminTenantId}
and name = 'user';
`);
// Insert `manage:tenant:self` scope.
const scopeId = generateStandardId();
await pool.query(sql`
insert into scopes (tenant_id, id, name, description, resource_id)
values (
${adminTenantId},
${scopeId},
'manage:tenant:self',
'Allow managing tenant itself, including update and delete.',
${resourceId}
);
`);
// Assign `manage:tenant:self` scope to `user` role.
await pool.query(sql`
insert into roles_scopes (tenant_id, id, role_id, scope_id)
values (
${adminTenantId},
${generateStandardId()},
${roleId},
${scopeId}
);
`);
},
down: async (pool) => {
// Delete `manage:tenant:self` scope.
// No need to delete `roles_scopes` because it will be cascade deleted.
await pool.query(sql`
delete from scopes
where tenant_id = ${adminTenantId} and name = 'manage:tenant:self';
`);
},
};
export default alteration;

View file

@ -10,8 +10,12 @@ import { adminTenantId } from './tenant.js';
export const cloudApiIndicator = 'https://cloud.logto.io/api';
export enum CloudScope {
/** The user can create a user tenant. */
CreateTenant = 'create:tenant',
/** The user can perform arbitrary operations on any tenant. */
ManageTenant = 'manage:tenant',
/** The user can update or delete its own tenants. */
ManageTenantSelf = 'manage:tenant:self',
SendSms = 'send:sms',
SendEmail = 'send:email',
}
@ -34,7 +38,13 @@ export const createCloudApi = (): Readonly<[UpdateAdminData, ...CreateScope[]]>
indicator: cloudApiIndicator,
name: `Logto Cloud API`,
},
scope: buildScope(CloudScope.CreateTenant, 'Allow creating new tenants.'),
scopes: [
buildScope(CloudScope.CreateTenant, 'Allow creating new tenants.'),
buildScope(
CloudScope.ManageTenantSelf,
'Allow managing tenant itself, including update and delete.'
),
],
role: {
tenantId: adminTenantId,
name: AdminTenantRole.User,

View file

@ -7,7 +7,7 @@ import { adminTenantId, defaultTenantId } from './tenant.js';
export type AdminData = {
resource: CreateResource;
scope: CreateScope;
scopes: CreateScope[];
role: CreateRole;
};
@ -35,15 +35,17 @@ export const defaultManagementApi = Object.freeze({
indicator: `https://${defaultTenantId}.logto.app/api`,
name: 'Logto Management API',
},
scope: {
tenantId: defaultTenantId,
/** @deprecated You should not rely on this constant. Change to something else. */
id: defaultScopeAllId,
name: PredefinedScope.All,
description: 'Default scope for Management API, allows all permissions.',
/** @deprecated You should not rely on this constant. Change to something else. */
resourceId: defaultResourceId,
},
scopes: [
{
tenantId: defaultTenantId,
/** @deprecated You should not rely on this constant. Change to something else. */
id: defaultScopeAllId,
name: PredefinedScope.All,
description: 'Default scope for Management API, allows all permissions.',
/** @deprecated You should not rely on this constant. Change to something else. */
resourceId: defaultResourceId,
},
],
role: {
tenantId: defaultTenantId,
/** @deprecated You should not rely on this constant. Change to something else. */
@ -79,13 +81,15 @@ export const createAdminData = (tenantId: string): AdminData => {
indicator: getManagementApiResourceIndicator(tenantId),
name: `Logto Management API`,
},
scope: {
tenantId,
id: generateStandardId(),
name: PredefinedScope.All,
description: 'Default scope for Management API, allows all permissions.',
resourceId,
},
scopes: [
{
tenantId,
id: generateStandardId(),
name: PredefinedScope.All,
description: 'Default scope for Management API, allows all permissions.',
resourceId,
},
],
role: {
tenantId,
id: generateStandardId(),
@ -106,13 +110,15 @@ export const createAdminDataInAdminTenant = (tenantId: string): AdminData => {
indicator: getManagementApiResourceIndicator(tenantId),
name: `Logto Management API for tenant ${tenantId}`,
},
scope: {
tenantId: adminTenantId,
id: generateStandardId(),
name: PredefinedScope.All,
description: 'Default scope for Management API, allows all permissions.',
resourceId,
},
scopes: [
{
tenantId: adminTenantId,
id: generateStandardId(),
name: PredefinedScope.All,
description: 'Default scope for Management API, allows all permissions.',
resourceId,
},
],
role: {
tenantId: adminTenantId,
id: generateStandardId(),
@ -132,13 +138,15 @@ export const createMeApiInAdminTenant = (): AdminData => {
indicator: getManagementApiResourceIndicator(adminTenantId, 'me'),
name: `Logto Me API`,
},
scope: {
tenantId: adminTenantId,
id: generateStandardId(),
name: PredefinedScope.All,
description: 'Default scope for Me API, allows all permissions.',
resourceId,
},
scopes: [
{
tenantId: adminTenantId,
id: generateStandardId(),
name: PredefinedScope.All,
description: 'Default scope for Me API, allows all permissions.',
resourceId,
},
],
role: {
tenantId: adminTenantId,
id: generateStandardId(),