0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

refactor: decouple admin tenant and user tenant

This commit is contained in:
Gao Sun 2023-02-11 14:38:16 +08:00
parent dd5b3037a8
commit 0481a450be
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
21 changed files with 163 additions and 89 deletions

View file

@ -1,7 +1,7 @@
import type { AdminConsoleKey } from '@logto/phrases';
import type { Application } from '@logto/schemas';
import { AppearanceMode, demoAppApplicationId } from '@logto/schemas';
import { useMemo } from 'react';
import { useContext, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import useSWR from 'swr';
@ -18,6 +18,7 @@ import Passwordless from '@/assets/images/passwordless.svg';
import SocialDark from '@/assets/images/social-dark.svg';
import Social from '@/assets/images/social.svg';
import { ConnectorsTabs } from '@/consts/page-tabs';
import { AppEndpointsContext } from '@/containers/AppEndpointsProvider';
import { RequestError } from '@/hooks/use-api';
import useConfigs from '@/hooks/use-configs';
import useDocumentationUrl from '@/hooks/use-documentation-url';
@ -37,6 +38,7 @@ type GetStartedMetadata = {
const useGetStartedMetadata = () => {
const { getDocumentationUrl } = useDocumentationUrl();
const { configs, updateConfigs } = useConfigs();
const { app } = useContext(AppEndpointsContext);
const theme = useTheme();
const isLightMode = theme === AppearanceMode.LightMode;
const { data: demoApp, error } = useSWR<Application, RequestError>(
@ -67,7 +69,7 @@ const useGetStartedMetadata = () => {
isHidden: hideDemo,
onClick: async () => {
void updateConfigs({ demoChecked: true });
window.open('/demo-app', '_blank');
window.open(new URL('/demo-app', app), '_blank');
},
},
{

View file

@ -23,6 +23,9 @@ mockEsm('#src/env-set/check-alteration-state.js', () => ({
// eslint-disable-next-line unicorn/consistent-function-scoping
mockEsmDefault('#src/env-set/oidc.js', () => () => ({
issuer: 'https://logto.test/oidc',
cookieKeys: [],
privateJwks: [],
publicJwks: [],
}));
/* End */

View file

@ -9,18 +9,24 @@ import RequestError from '#src/errors/RequestError/index.js';
import { mockEnvSet } from '#src/test-utils/env-set.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import type { WithAuthContext } from './koa-auth.js';
import type { WithAuthContext } from './index.js';
const { jest } = import.meta;
const { mockEsm } = createMockUtils(jest);
mockEsm('./utils.js', () => ({
getAdminTenantTokenValidationSet: jest.fn().mockResolvedValue({ keys: [], issuer: [] }),
}));
const { jwtVerify } = mockEsm('jose', () => ({
createLocalJWKSet: jest.fn(),
jwtVerify: jest
.fn()
.mockReturnValue({ payload: { sub: 'fooUser', scope: defaultManagementApi.scope.name } }),
}));
const koaAuth = await pickDefault(import('./koa-auth.js'));
const audience = defaultManagementApi.resource.indicator;
const koaAuth = await pickDefault(import('./index.js'));
describe('koaAuth middleware', () => {
const baseCtx = createContextWithRouteParameters();
@ -64,7 +70,7 @@ describe('koaAuth middleware', () => {
developmentUserId: 'foo',
});
await koaAuth(mockEnvSet)(ctx, next);
await koaAuth(mockEnvSet, audience)(ctx, next);
expect(ctx.auth).toEqual({ type: 'user', id: 'foo' });
stub.restore();
@ -79,7 +85,7 @@ describe('koaAuth middleware', () => {
},
};
await koaAuth(mockEnvSet)(mockCtx, next);
await koaAuth(mockEnvSet, audience)(mockCtx, next);
expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo' });
});
@ -91,7 +97,7 @@ describe('koaAuth middleware', () => {
isIntegrationTest: true,
});
await koaAuth(mockEnvSet)(ctx, next);
await koaAuth(mockEnvSet, audience)(ctx, next);
expect(ctx.auth).toEqual({ type: 'user', id: 'foo' });
stub.restore();
@ -112,7 +118,7 @@ describe('koaAuth middleware', () => {
},
};
await koaAuth(mockEnvSet)(mockCtx, next);
await koaAuth(mockEnvSet, audience)(mockCtx, next);
expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo' });
stub.restore();
@ -125,12 +131,14 @@ describe('koaAuth middleware', () => {
authorization: 'Bearer access_token',
},
};
await koaAuth(mockEnvSet)(ctx, next);
await koaAuth(mockEnvSet, audience)(ctx, next);
expect(ctx.auth).toEqual({ type: 'user', id: 'fooUser' });
});
it('expect to throw if authorization header is missing', async () => {
await expect(koaAuth(mockEnvSet)(ctx, next)).rejects.toMatchError(authHeaderMissingError);
await expect(koaAuth(mockEnvSet, audience)(ctx, next)).rejects.toMatchError(
authHeaderMissingError
);
});
it('expect to throw if authorization header token type not recognized ', async () => {
@ -141,7 +149,9 @@ describe('koaAuth middleware', () => {
},
};
await expect(koaAuth(mockEnvSet)(ctx, next)).rejects.toMatchError(tokenNotSupportedError);
await expect(koaAuth(mockEnvSet, audience)(ctx, next)).rejects.toMatchError(
tokenNotSupportedError
);
});
it('expect to throw if jwt sub is missing', async () => {
@ -154,11 +164,13 @@ describe('koaAuth middleware', () => {
},
};
await expect(koaAuth(mockEnvSet)(ctx, next)).rejects.toMatchError(jwtSubMissingError);
await expect(koaAuth(mockEnvSet, audience)(ctx, next)).rejects.toMatchError(jwtSubMissingError);
});
it('expect to have `client` type per jwt verify result', async () => {
jwtVerify.mockImplementationOnce(() => ({ payload: { sub: 'bar', client_id: 'bar' } }));
jwtVerify.mockImplementationOnce(() => ({
payload: { sub: 'bar', client_id: 'bar', scope: 'all' },
}));
ctx.request = {
...ctx.request,
@ -167,7 +179,7 @@ describe('koaAuth middleware', () => {
},
};
await koaAuth(mockEnvSet)(ctx, next);
await koaAuth(mockEnvSet, audience)(ctx, next);
expect(ctx.auth).toEqual({ type: 'app', id: 'bar' });
});
@ -181,7 +193,7 @@ describe('koaAuth middleware', () => {
},
};
await expect(koaAuth(mockEnvSet)(ctx, next)).rejects.toMatchError(forbiddenError);
await expect(koaAuth(mockEnvSet, audience)(ctx, next)).rejects.toMatchError(forbiddenError);
});
it('expect to throw if jwt scope does not include management resource scope', async () => {
@ -196,7 +208,7 @@ describe('koaAuth middleware', () => {
},
};
await expect(koaAuth(mockEnvSet)(ctx, next)).rejects.toMatchError(forbiddenError);
await expect(koaAuth(mockEnvSet, audience)(ctx, next)).rejects.toMatchError(forbiddenError);
});
it('expect to throw unauthorized error if unknown error occurs', async () => {
@ -210,7 +222,7 @@ describe('koaAuth middleware', () => {
},
};
await expect(koaAuth(mockEnvSet)(ctx, next)).rejects.toMatchError(
await expect(koaAuth(mockEnvSet, audience)(ctx, next)).rejects.toMatchError(
new RequestError({ code: 'auth.unauthorized', status: 401 }, new Error('unknown error'))
);
});

View file

@ -1,6 +1,6 @@
import type { IncomingHttpHeaders } from 'http';
import { adminTenantId, defaultManagementApi } from '@logto/schemas';
import { adminTenantId, defaultManagementApi, PredefinedScope } from '@logto/schemas';
import type { Optional } from '@silverhand/essentials';
import type { JWK } from 'jose';
import { createLocalJWKSet, jwtVerify } from 'jose';
@ -10,9 +10,10 @@ import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import { tenantPool } from '#src/tenants/index.js';
import assertThat from '#src/utils/assert-that.js';
import { getAdminTenantTokenValidationSet } from './utils.js';
export type Auth = {
type: 'user' | 'app';
id: string;
@ -68,13 +69,11 @@ export const verifyBearerTokenFromRequest = async (
return [publicJwks, [issuer]];
}
const {
envSet: { oidc: adminOidc },
} = await tenantPool.get(adminTenantId);
const adminSet = await getAdminTenantTokenValidationSet();
return [
[...publicJwks, ...adminOidc.publicJwks],
[issuer, adminOidc.issuer],
[...publicJwks, ...adminSet.keys],
[issuer, ...adminSet.issuer],
];
};
@ -105,8 +104,7 @@ export const verifyBearerTokenFromRequest = async (
export default function koaAuth<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
envSet: EnvSet,
audience: string,
expectScopes = [defaultManagementApi.scope.name]
audience: string
): MiddlewareType<StateT, WithAuthContext<ContextT>, ResponseBodyT> {
return async (ctx, next) => {
const { sub, clientId, scopes } = await verifyBearerTokenFromRequest(
@ -116,7 +114,7 @@ export default function koaAuth<StateT, ContextT extends IRouterParamContext, Re
);
assertThat(
expectScopes.every((scope) => scopes.includes(scope)),
scopes.includes(PredefinedScope.All),
new RequestError({ code: 'auth.forbidden', status: 403 })
);

View file

@ -0,0 +1,54 @@
import crypto from 'crypto';
import type { LogtoConfig } from '@logto/schemas';
import {
logtoOidcConfigGuard,
adminTenantId,
LogtoOidcConfigKey,
LogtoConfigs,
} from '@logto/schemas';
import { convertToIdentifiers } from '@logto/shared';
import type { JWK } from 'jose';
import { sql } from 'slonik';
import { EnvSet } from '#src/env-set/index.js';
import { exportJWK } from '#src/utils/jwks.js';
const { table, fields } = convertToIdentifiers(LogtoConfigs);
/**
* This function is to fetch OIDC public signing keys and the issuer from the admin tenant
* in order to let user tenants recognize Access Tokens issued by the admin tenant.
*
* Usually you don't mean to call this function.
*/
export const getAdminTenantTokenValidationSet = async (): Promise<{
keys: JWK[];
issuer: string[];
}> => {
const { isDomainBasedMultiTenancy, urlSet, adminUrlSet } = EnvSet.values;
if (!isDomainBasedMultiTenancy && adminUrlSet.deduplicated().length === 0) {
return { keys: [], issuer: [] };
}
const pool = await EnvSet.pool;
const { value } = await pool.one<LogtoConfig>(sql`
select ${sql.join([fields.key, fields.value], sql`,`)} from ${table}
where ${fields.tenantId} = ${adminTenantId}
and ${fields.key} = ${LogtoOidcConfigKey.PrivateKeys}
`);
const privateKeys = logtoOidcConfigGuard['oidc.privateKeys']
.parse(value)
.map((key) => crypto.createPrivateKey(key));
const publicKeys = privateKeys.map((key) => crypto.createPublicKey(key));
return {
keys: await Promise.all(publicKeys.map(async (key) => exportJWK(key))),
issuer: [
(isDomainBasedMultiTenancy
? urlSet.endpoint.replace('*', adminTenantId)
: adminUrlSet.endpoint) + '/oidc',
],
};
};

View file

@ -2,14 +2,13 @@ import {
adminTenantId,
arbitraryObjectGuard,
getManagementApiResourceIndicator,
PredefinedScope,
} from '@logto/schemas';
import Koa from 'koa';
import Router from 'koa-router';
import RequestError from '#src/errors/RequestError/index.js';
import type { WithAuthContext } from '#src/middleware/koa-auth.js';
import koaAuth from '#src/middleware/koa-auth.js';
import type { WithAuthContext } from '#src/middleware/koa-auth/index.js';
import koaAuth from '#src/middleware/koa-auth/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';
@ -25,9 +24,7 @@ export default function initMeApis(tenant: TenantContext): Koa {
console.log('????', getManagementApiResourceIndicator(adminTenantId, 'me'));
meRouter.use(
koaAuth(tenant.envSet, getManagementApiResourceIndicator(adminTenantId, 'me'), [
PredefinedScope.All,
]),
koaAuth(tenant.envSet, getManagementApiResourceIndicator(adminTenantId, 'me')),
async (ctx, next) => {
assertThat(
ctx.auth.type === 'user',

View file

@ -1,7 +1,7 @@
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import { verifyBearerTokenFromRequest } from '#src/middleware/koa-auth.js';
import { verifyBearerTokenFromRequest } from '#src/middleware/koa-auth/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import assertThat from '#src/utils/assert-that.js';

View file

@ -1,12 +1,12 @@
import cors from '@koa/cors';
import { getManagementApiResourceIndicator, PredefinedScope } from '@logto/schemas';
import { getManagementApiResourceIndicator } from '@logto/schemas';
import Koa from 'koa';
import Router from 'koa-router';
import { EnvSet } from '#src/env-set/index.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import koaAuth from '../middleware/koa-auth.js';
import koaAuth from '../middleware/koa-auth/index.js';
import adminUserRoleRoutes from './admin-user-role.js';
import adminUserRoutes from './admin-user.js';
import applicationRoutes from './application.js';
@ -35,9 +35,7 @@ const createRouters = (tenant: TenantContext) => {
interactionRoutes(interactionRouter, tenant);
const managementRouter: AuthedRouter = new Router();
managementRouter.use(
koaAuth(tenant.envSet, getManagementApiResourceIndicator(tenant.id), [PredefinedScope.All])
);
managementRouter.use(koaAuth(tenant.envSet, getManagementApiResourceIndicator(tenant.id)));
applicationRoutes(managementRouter, tenant);
logtoConfigRoutes(managementRouter, tenant);
connectorRoutes(managementRouter, tenant);

View file

@ -1,4 +1,4 @@
import { InteractionEvent, adminConsoleApplicationId } from '@logto/schemas';
import { InteractionEvent, adminConsoleApplicationId, adminTenantId } from '@logto/schemas';
import { createMockUtils, pickDefault } from '@logto/shared/esm';
import type Provider from 'oidc-provider';
@ -31,6 +31,10 @@ const { encryptUserPassword } = mockEsm('#src/libraries/user.js', () => ({
}),
}));
mockEsm('#src/utils/tenant.js', () => ({
getTenantId: () => adminTenantId,
}));
const userQueries = {
findUserById: jest
.fn()
@ -115,7 +119,7 @@ describe('submit action', () => {
id: 'uid',
...upsertProfile,
},
false
[]
);
expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, {
login: { accountId: 'uid' },
@ -153,7 +157,7 @@ describe('submit action', () => {
id: 'uid',
...upsertProfile,
},
true
['user', 'default:admin']
);
expect(assignInteractionResults).toBeCalledWith(adminConsoleCtx, tenant.provider, {
login: { accountId: 'uid' },

View file

@ -174,7 +174,7 @@ export default async function submitInteraction(
id,
...upsertProfile,
},
createAdminUser ? [getManagementApiAdminName(defaultTenantId), UserRole.User] : []
createAdminUser ? [UserRole.User, getManagementApiAdminName(defaultTenantId)] : []
);
await assignInteractionResults(ctx, provider, { login: { accountId: id } });

View file

@ -2,7 +2,7 @@ import type { ExtendableContext } from 'koa';
import type Router from 'koa-router';
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
import type { WithAuthContext } from '#src/middleware/koa-auth.js';
import type { WithAuthContext } from '#src/middleware/koa-auth/index.js';
import type { WithI18nContext } from '#src/middleware/koa-i18next.js';
import type TenantContext from '#src/tenants/TenantContext.js';

View file

@ -111,11 +111,12 @@ describe('GET /.well-known/sign-in-exp', () => {
expect(response.body).toMatchObject({
...adminConsoleSignInExperience,
tenantId: 'admin',
branding: {
...adminConsoleSignInExperience.branding,
slogan: 'admin_console.welcome.title',
},
termsOfUseUrl: mockSignInExperience.termsOfUseUrl,
termsOfUseUrl: null,
languageInfo: mockSignInExperience.languageInfo,
socialConnectors: [],
signInMode: SignInMode.SignIn,

View file

@ -79,20 +79,20 @@ export default class Tenant implements TenantContext {
// Mount APIs
app.use(mount('/api', initApis(tenantContext)));
// Mount `/me` APIs for admin tenant
// Mount admin tenant APIs and app
if (id === adminTenantId) {
console.log('111111111111122221');
// Mount `/me` APIs for admin tenant
app.use(mount('/me', initMeApis(tenantContext)));
}
// Mount Admin Console
app.use(koaConsoleRedirectProxy(queries));
app.use(
mount(
'/' + UserApps.Console,
koaSpaProxy(mountedApps, UserApps.Console, 5002, UserApps.Console)
)
);
// Mount Admin Console
app.use(koaConsoleRedirectProxy(queries));
app.use(
mount(
'/' + UserApps.Console,
koaSpaProxy(mountedApps, UserApps.Console, 5002, UserApps.Console)
)
);
}
// Mount demo app
app.use(

View file

@ -0,0 +1,5 @@
import type TenantContext from './TenantContext.js';
export default abstract class TenantPoolContext<Tenant extends TenantContext = TenantContext> {
public abstract get(tenantId: string): Promise<Tenant>;
}

View file

@ -2,7 +2,7 @@ import LRUCache from 'lru-cache';
import Tenant from './Tenant.js';
class TenantPool {
export class TenantPool {
protected cache = new LRUCache<string, Tenant>({ max: 500 });
async get(tenantId: string): Promise<Tenant> {

View file

@ -5,7 +5,6 @@ export * from './sign-in-experience.js';
export * from './admin-user.js';
export * from './logs.js';
export * from './dashboard.js';
export * from './me.js';
export * from './wellknown.js';
export * from './interaction.js';

View file

@ -1,4 +1,4 @@
import { adminRoleId } from '@logto/schemas';
import { defaultManagementApi } from '@logto/schemas';
import { HTTPError } from 'got';
import { assignRolesToUser, getUserRoles, deleteRoleFromUser } from '#src/api/index.js';
@ -25,8 +25,8 @@ describe('admin console user management (roles)', () => {
it('should delete role from user successfully', async () => {
const user = await createUserByAdmin();
await assignRolesToUser(user.id, [adminRoleId]);
await deleteRoleFromUser(user.id, adminRoleId);
await assignRolesToUser(user.id, [defaultManagementApi.role.id]);
await deleteRoleFromUser(user.id, defaultManagementApi.role.id);
const roles = await getUserRoles(user.id);
expect(roles.length).toBe(0);
@ -35,7 +35,7 @@ describe('admin console user management (roles)', () => {
it('should delete non-exist-role from user failed', async () => {
const user = await createUserByAdmin();
const response = await deleteRoleFromUser(user.id, adminRoleId).catch(
const response = await deleteRoleFromUser(user.id, defaultManagementApi.role.id).catch(
(error: unknown) => error
);
expect(response instanceof HTTPError && response.response.statusCode === 404).toBe(true);

View file

@ -1,12 +1,7 @@
import path from 'path';
import { fetchTokenByRefreshToken } from '@logto/js';
import {
managementResource,
InteractionEvent,
adminRoleId,
managementResourceScope,
} from '@logto/schemas';
import { defaultManagementApi, InteractionEvent } from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import fetch from 'node-fetch';
@ -27,14 +22,14 @@ describe('get access token', () => {
beforeAll(async () => {
await createUserByAdmin(guestUsername, password);
const user = await createUserByAdmin(username, password);
await assignUsersToRole([user.id], adminRoleId);
await assignUsersToRole([user.id], defaultManagementApi.role.id);
await enableAllPasswordSignInMethods();
});
it('sign-in and getAccessToken with admin user', async () => {
const client = new MockClient({
resources: [managementResource.indicator],
scopes: [managementResourceScope.name],
resources: [defaultManagementApi.resource.indicator],
scopes: [defaultManagementApi.scope.name],
});
await client.initSession();
await client.successSend(putInteraction, {
@ -43,11 +38,11 @@ describe('get access token', () => {
});
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
const accessToken = await client.getAccessToken(managementResource.indicator);
const accessToken = await client.getAccessToken(defaultManagementApi.resource.indicator);
expect(accessToken).not.toBeNull();
expect(getAccessTokenPayload(accessToken)).toHaveProperty(
'scope',
managementResourceScope.name
defaultManagementApi.scope.name
);
// Request for invalid resource should throw
@ -56,8 +51,8 @@ describe('get access token', () => {
it('sign-in and getAccessToken with guest user', async () => {
const client = new MockClient({
resources: [managementResource.indicator],
scopes: [managementResourceScope.name],
resources: [defaultManagementApi.resource.indicator],
scopes: [defaultManagementApi.scope.name],
});
await client.initSession();
await client.successSend(putInteraction, {
@ -66,16 +61,16 @@ describe('get access token', () => {
});
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
const accessToken = await client.getAccessToken(managementResource.indicator);
const accessToken = await client.getAccessToken(defaultManagementApi.resource.indicator);
expect(getAccessTokenPayload(accessToken)).not.toHaveProperty(
'scope',
managementResourceScope.name
defaultManagementApi.scope.name
);
});
it('sign-in and get multiple Access Token by the same Refresh Token within refreshTokenReuseInterval', async () => {
const client = new MockClient({ resources: [managementResource.indicator] });
const client = new MockClient({ resources: [defaultManagementApi.resource.indicator] });
await client.initSession();
@ -98,7 +93,7 @@ describe('get access token', () => {
clientId: defaultConfig.appId,
tokenEndpoint: path.join(logtoUrl, '/oidc/token'),
refreshToken,
resource: managementResource.indicator,
resource: defaultManagementApi.resource.indicator,
},
async <T>(...args: Parameters<typeof fetch>): Promise<T> => {
const response = await fetch(...args);

View file

@ -1,4 +1,4 @@
import { managementResource, managementResourceScope } from '@logto/schemas';
import { defaultManagementApi } from '@logto/schemas';
import { HTTPError } from 'got';
import { createResource } from '#src/api/index.js';
@ -7,9 +7,9 @@ import { generateScopeName } from '#src/utils.js';
describe('scopes', () => {
it('should get management api resource scopes successfully', async () => {
const scopes = await getScopes(managementResource.id);
const scopes = await getScopes(defaultManagementApi.resource.id);
expect(scopes[0]).toMatchObject(managementResourceScope);
expect(scopes[0]).toMatchObject(defaultManagementApi.scope);
});
it('should create scope successfully', async () => {

View file

@ -1,4 +1,4 @@
import { managementResource } from '@logto/schemas';
import { defaultManagementApi } from '@logto/schemas';
import { HTTPError } from 'got';
import { createResource, getResource, updateResource, deleteResource } from '#src/api/index.js';
@ -6,9 +6,9 @@ import { generateResourceIndicator, generateResourceName } from '#src/utils.js';
describe('admin console api resources', () => {
it('should get management api resource details successfully', async () => {
const fetchedManagementApiResource = await getResource(managementResource.id);
const fetchedManagementApiResource = await getResource(defaultManagementApi.resource.id);
expect(fetchedManagementApiResource).toMatchObject(managementResource);
expect(fetchedManagementApiResource).toMatchObject(defaultManagementApi.resource);
});
it('should create api resource successfully', async () => {

View file

@ -6,9 +6,9 @@ create table users (
tenant_id varchar(21) not null
references tenants (id) on update cascade on delete cascade,
id varchar(12) not null,
username varchar(128) unique,
primary_email varchar(128) unique,
primary_phone varchar(128) unique,
username varchar(128),
primary_email varchar(128),
primary_phone varchar(128),
password_encrypted varchar(128),
password_encryption_method users_password_encryption_method,
name varchar(128),
@ -19,7 +19,13 @@ create table users (
is_suspended boolean not null default false,
last_sign_in_at timestamptz,
created_at timestamptz not null default (now()),
primary key (id)
primary key (id),
constraint users__username
unique (tenant_id, username),
constraint users__primary_email
unique (tenant_id, primary_email),
constraint users__primary_phone
unique (tenant_id, primary_phone)
);
create index users__id