Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
"pg-protocol": "^1.6.0",
"pkg-dir": "^8.0.0",
"pluralize": "^8.0.0",
"posthog-node": "^5.9.2",
"qrcode": "^1.5.3",
"raw-body": "^3.0.0",
"redis": "^4.6.14",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import RequestError from '#src/errors/RequestError/index.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';

import { ensembleSamlApplication, generateKeyPairAndCertificate } from './utils.js';
import { assembleSamlApplication, generateKeyPairAndCertificate } from './utils.js';

const consoleLog = new ConsoleLog(chalk.magenta('SAML app custom domain'));

Expand Down Expand Up @@ -75,7 +75,7 @@ export const createSamlApplicationsLibrary = (queries: Queries) => {

const samlConfig = await findSamlApplicationConfigByApplicationId(application.id);

return ensembleSamlApplication({ application, samlConfig });
return assembleSamlApplication({ application, samlConfig });
};

const updateSamlApplicationById = async (
Expand Down Expand Up @@ -112,7 +112,7 @@ export const createSamlApplicationsLibrary = (queries: Queries) => {
: originalAppConfig,
]);

return ensembleSamlApplication({
return assembleSamlApplication({
application: updatedApplication,
samlConfig: upToDateSamlConfig,
});
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/libraries/saml-application/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export const calculateCertificateFingerprints = (
* - A record from the `applications` table with a `type` of `SAML`
* - A record from the `saml_application_configs` table
*/
export const ensembleSamlApplication = ({
export const assembleSamlApplication = ({
application,
samlConfig,
}: {
Expand Down
12 changes: 6 additions & 6 deletions packages/core/src/libraries/user.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { MfaFactor, UsersPasswordEncryptionMethod } from '@logto/schemas';
import { defaultTenantId, MfaFactor, UsersPasswordEncryptionMethod } from '@logto/schemas';
import { createMockUtils } from '@logto/shared/esm';

import { mockResource, mockAdminUserRole, mockScope } from '#src/__mocks__/index.js';
Expand Down Expand Up @@ -48,7 +48,7 @@ const queries = new MockQueries({
});

describe('generateUserId()', () => {
const { generateUserId } = createUserLibrary(queries);
const { generateUserId } = createUserLibrary(defaultTenantId, queries);

afterEach(() => {
hasUserWithId.mockClear();
Expand Down Expand Up @@ -98,7 +98,7 @@ describe('encryptUserPassword()', () => {
});

describe('verifyUserPassword()', () => {
const { verifyUserPassword } = createUserLibrary(queries);
const { verifyUserPassword } = createUserLibrary(defaultTenantId, queries);

describe('Argon2i', () => {
it('resolves when password is correct', async () => {
Expand Down Expand Up @@ -235,7 +235,7 @@ describe('verifyUserPassword()', () => {
});

describe('findUserScopesForResourceId()', () => {
const { findUserScopesForResourceIndicator } = createUserLibrary(queries);
const { findUserScopesForResourceIndicator } = createUserLibrary(defaultTenantId, queries);

it('returns scopes that the user has access', async () => {
await expect(
Expand All @@ -245,7 +245,7 @@ describe('findUserScopesForResourceId()', () => {
});

describe('findUserRoles()', () => {
const { findUserRoles } = createUserLibrary(queries);
const { findUserRoles } = createUserLibrary(defaultTenantId, queries);

it('returns user roles', async () => {
await expect(findUserRoles(mockUser.id)).resolves.toEqual([mockAdminUserRole]);
Expand All @@ -254,7 +254,7 @@ describe('findUserRoles()', () => {

describe('addUserMfaVerification()', () => {
const createdAt = new Date().toISOString();
const { addUserMfaVerification } = createUserLibrary(queries);
const { addUserMfaVerification } = createUserLibrary(defaultTenantId, queries);

beforeAll(() => {
jest.useFakeTimers();
Expand Down
34 changes: 29 additions & 5 deletions packages/core/src/libraries/user.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { BindMfa, CreateUser, Scope, User } from '@logto/schemas';
import { RoleType, UsersPasswordEncryptionMethod } from '@logto/schemas';
import {
adminTenantId,
ProductEvent,
RoleType,
UsersPasswordEncryptionMethod,
} from '@logto/schemas';
import { generateStandardShortId, generateStandardId } from '@logto/shared';
import type { Nullable } from '@silverhand/essentials';
import { deduplicateByKey, condArray } from '@silverhand/essentials';
Expand All @@ -15,13 +20,15 @@ import assertThat from '#src/utils/assert-that.js';
import { legacyVerify } from '#src/utils/password.js';
import type { OmitAutoSetFields } from '#src/utils/sql.js';

import { captureDeveloperEvent } from '../utils/posthog.js';

import { convertBindMfaToMfaVerification, encryptUserPassword } from './user.utils.js';

export type InsertUserResult = [User];

export type UserLibrary = ReturnType<typeof createUserLibrary>;

export const createUserLibrary = (queries: Queries) => {
export const createUserLibrary = (tenantId: string, queries: Queries) => {
const {
pool,
roles: { findDefaultRoles, findRolesByRoleNames, findRoleByRoleName, findRolesByRoleIds },
Expand Down Expand Up @@ -59,19 +66,30 @@ export const createUserLibrary = (queries: Queries) => {
{ retries, factor: 0 } // No need for exponential backoff
);

type InsertUserOptions = {
/** Additional role names to assign to the user upon creation. */
roleNames?: string[];
/**
* Whether the user is created via an interactive flow (e.g. sign up).
* @default false
*/
isInteractive?: boolean;
};

const insertUser = async (
data: OmitAutoSetFields<CreateUser>,
additionalRoleNames: string[]
options?: InsertUserOptions
): Promise<InsertUserResult> => {
const roleNames = [...EnvSet.values.userDefaultRoleNames, ...additionalRoleNames];
const { isInteractive = false } = options ?? {};
const roleNames = [...EnvSet.values.userDefaultRoleNames, ...(options?.roleNames ?? [])];
const [parameterRoles, defaultRoles] = await Promise.all([
findRolesByRoleNames(roleNames),
findDefaultRoles(RoleType.User),
]);

assertThat(parameterRoles.length === roleNames.length, 'role.default_role_missing');

return pool.transaction(async (connection) => {
const result = await pool.transaction<[User]>(async (connection) => {
const user = await insertUserQuery(data);
const roles = deduplicateByKey([...parameterRoles, ...defaultRoles], 'id');

Expand All @@ -84,6 +102,12 @@ export const createUserLibrary = (queries: Queries) => {

return [user];
});

if (tenantId === adminTenantId) {
captureDeveloperEvent(result[0].id, ProductEvent.DeveloperCreated, { isInteractive });
}

return result;
};

const checkIdentifierCollision = async (
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import initI18n from './i18n/init.js';
import SystemContext from './tenants/SystemContext.js';
import { tenantPool } from './tenants/index.js';
import { loadConnectorFactories } from './utils/connectors/index.js';
import { shutdownPostHog } from './utils/posthog.js';

const consoleLog = new ConsoleLog(chalk.magenta('index'));

Expand Down Expand Up @@ -54,5 +55,9 @@ try {
consoleLog.error('Error while initializing app:');
consoleLog.error(error);

void Promise.all([trySafe(tenantPool.endAll()), trySafe(redisCache.disconnect())]);
void Promise.all([
trySafe(tenantPool.endAll()),
trySafe(redisCache.disconnect()),
shutdownPostHog(),
]);
}
45 changes: 24 additions & 21 deletions packages/core/src/routes/admin-user/basics.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
/* eslint-disable max-lines */
import { emailRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
import {
ProductEvent,
UsersPasswordEncryptionMethod,
adminTenantId,
jsonObjectGuard,
userProfileGuard,
userProfileResponseGuard,
Expand All @@ -16,13 +18,14 @@ import koaGuard from '#src/middleware/koa-guard.js';
import assertThat from '#src/utils/assert-that.js';

import { parseLegacyPassword } from '../../utils/password.js';
import { captureDeveloperEvent } from '../../utils/posthog.js';
import { transpileUserProfileResponse } from '../../utils/user.js';
import type { ManagementApiRouter, RouterInitArgs } from '../types.js';

export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
...args: RouterInitArgs<T>
) {
const [router, { queries, libraries }] = args;
const [router, { queries, libraries, id: tenantId }] = args;
const {
users: {
deleteUserById,
Expand Down Expand Up @@ -201,26 +204,23 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(

const id = await generateUserId();

const [user] = await insertUser(
{
id,
primaryEmail,
primaryPhone,
username,
name,
avatar,
...conditional(customData && { customData }),
...conditional(password && (await encryptUserPassword(password))),
...conditional(
passwordDigest && {
passwordEncrypted: passwordDigest,
passwordEncryptionMethod: passwordAlgorithm,
}
),
...conditional(profile && { profile }),
},
[]
);
const [user] = await insertUser({
id,
primaryEmail,
primaryPhone,
username,
name,
avatar,
...conditional(customData && { customData }),
...conditional(password && (await encryptUserPassword(password))),
...conditional(
passwordDigest && {
passwordEncrypted: passwordDigest,
passwordEncryptionMethod: passwordAlgorithm,
}
),
...conditional(profile && { profile }),
});

ctx.body = transpileUserProfileResponse(user);
return next();
Expand Down Expand Up @@ -379,6 +379,9 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
await signOutUser(userId);
await deleteUserById(userId);

if (tenantId === adminTenantId) {
captureDeveloperEvent(userId, ProductEvent.DeveloperDeleted);
}
ctx.status = 204;

// Manually trigger the `User.Deleted` hook since we need to send the user data in the payload
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/routes/applications/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
demoAppApplicationId,
hasSecrets,
InternalRole,
ProductEvent,
} from '@logto/schemas';
import { generateStandardId, generateStandardSecret } from '@logto/shared';
import { conditional } from '@silverhand/essentials';
Expand All @@ -20,6 +21,7 @@ import { buildOidcClientMetadata } from '#src/oidc/utils.js';
import assertThat from '#src/utils/assert-that.js';
import { parseSearchParamsForSearch } from '#src/utils/search.js';

import { captureEvent } from '../../utils/posthog.js';
import type { ManagementApiRouter, RouterInitArgs } from '../types.js';

import applicationCustomDataRoutes from './application-custom-data.js';
Expand Down Expand Up @@ -230,6 +232,10 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
void quota.reportSubscriptionUpdatesUsage('thirdPartyApplicationsLimit');
}

captureEvent(tenantId, ProductEvent.AppCreated, {
type: rest.type,
isThirdParty: rest.isThirdParty ?? false,
});
return next();
}
);
Expand Down Expand Up @@ -392,6 +398,11 @@ export default function applicationRoutes<T extends ManagementApiRouter>(
void quota.reportSubscriptionUpdatesUsage('thirdPartyApplicationsLimit');
}

captureEvent(tenantId, ProductEvent.AppDeleted, {
type,
isThirdParty,
});

return next();
}
);
Expand Down
32 changes: 30 additions & 2 deletions packages/core/src/routes/connector/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { type ConnectorFactory } from '@logto/cli/lib/connector/index.js';
import type router from '@logto/cloud/routes';
import { demoConnectorIds, validateConfig } from '@logto/connector-kit';
import { Connectors, ConnectorType, connectorResponseGuard, type JsonObject } from '@logto/schemas';
import {
Connectors,
ConnectorType,
connectorResponseGuard,
type JsonObject,
ProductEvent,
} from '@logto/schemas';
import { generateStandardShortId } from '@logto/shared';
import { conditional } from '@silverhand/essentials';
import cleanDeep from 'clean-deep';
Expand All @@ -16,6 +22,7 @@ import { buildExtraInfo } from '#src/utils/connectors/extra-information.js';
import { loadConnectorFactories, transpileLogtoConnector } from '#src/utils/connectors/index.js';
import { checkSocialConnectorTargetAndPlatformUniqueness } from '#src/utils/connectors/platform.js';

import { captureEvent } from '../../utils/posthog.js';
import type { ManagementApiRouter, RouterInitArgs } from '../types.js';

import connectorAuthorizationUriRoutes from './authorization-uri.js';
Expand All @@ -32,6 +39,10 @@ const guardConnectorsQuota = async (
};

const passwordlessConnector = new Set([ConnectorType.Email, ConnectorType.Sms]);
const pickFactoryProperties = <T extends ConnectorFactory<typeof router>>(factory: T) => ({
type: factory.type,
name: factory.metadata.name.en,
});

export default function connectorRoutes<T extends ManagementApiRouter>(
...[router, tenant]: RouterInitArgs<T>
Expand Down Expand Up @@ -181,6 +192,18 @@ export default function connectorRoutes<T extends ManagementApiRouter>(
if (conflictingConnectorIds.length > 0) {
await deleteConnectorByIds(conflictingConnectorIds);
}

captureEvent(
tenant.id,
ProductEvent.PasswordlessConnectorUpdated,
pickFactoryProperties(connectorFactory)
);
} else {
captureEvent(
tenant.id,
ProductEvent.SocialConnectorCreated,
pickFactoryProperties(connectorFactory)
);
}

const connector = await getLogtoConnectorById(insertConnectorId);
Expand Down Expand Up @@ -347,6 +370,7 @@ export default function connectorRoutes<T extends ManagementApiRouter>(
);

router.delete(
// eslint-disable-next-line max-lines -- refactor later
'/connectors/:id',
koaGuard({ params: object({ id: string().min(1) }), status: [204, 404] }),
async (ctx, next) => {
Expand All @@ -365,6 +389,11 @@ export default function connectorRoutes<T extends ManagementApiRouter>(

if (connectorFactory?.type === ConnectorType.Social) {
await removeUnavailableSocialConnectorTargets();
captureEvent(
tenant.id,
ProductEvent.SocialConnectorDeleted,
pickFactoryProperties(connectorFactory)
);
}

ctx.status = 204;
Expand All @@ -376,5 +405,4 @@ export default function connectorRoutes<T extends ManagementApiRouter>(
connectorConfigTestingRoutes(router, tenant);
connectorAuthorizationUriRoutes(router, tenant);
connectorFactoryRoutes(router, tenant);
// eslint-disable-next-line max-lines
}
Loading
Loading