Skip to content
Open
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
6 changes: 6 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Ensure shell scripts always check out with LF endings, even on Windows.
# Without this, Docker builds on Alpine fail with "unexpected end of file"
# because busybox sh cannot parse CRLF line endings.
*.sh text eol=lf
Dockerfile* text eol=lf
docker-compose*.yml text eol=lf
7 changes: 6 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
###### [STAGE] Build ######
FROM node:22-alpine as builder

Check warning on line 2 in Dockerfile

View workflow job for this annotation

GitHub Actions / main-dockerize

The 'as' keyword should match the case of the 'from' keyword

FromAsCasing: 'as' and 'FROM' keywords' casing do not match More info: https://docs.docker.com/go/dockerfile/rule/from-as-casing/
WORKDIR /etc/logto
ENV CI=true

Expand All @@ -9,10 +9,15 @@
### Install toolchain ###
RUN npm add --location=global pnpm@^10.0.0
# https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md#node-gyp-alpine
RUN apk add --no-cache python3 make g++ rsync
RUN apk add --no-cache python3 make g++ rsync dos2unix

COPY . .

# Normalize line endings for shell scripts. When the repo is checked out on
# Windows, git may convert LF -> CRLF in the working tree, which breaks
# busybox sh inside Alpine with errors like "unexpected end of file".
RUN find . -type f -name "*.sh" -not -path "./node_modules/*" -exec dos2unix {} +

### Install dependencies and build ###
RUN pnpm i

Expand All @@ -38,7 +43,7 @@
RUN rm -rf .scripts pnpm-*.yaml packages/cloud

###### [STAGE] Seal ######
FROM node:22-alpine as app

Check warning on line 46 in Dockerfile

View workflow job for this annotation

GitHub Actions / main-dockerize

The 'as' keyword should match the case of the 'from' keyword

FromAsCasing: 'as' and 'FROM' keywords' casing do not match More info: https://docs.docker.com/go/dockerfile/rule/from-as-casing/
WORKDIR /etc/logto
COPY --from=builder /etc/logto .
RUN mkdir -p /etc/logto/packages/cli/alteration-scripts && chmod g+w /etc/logto/packages/cli/alteration-scripts
Expand Down
49 changes: 49 additions & 0 deletions docker-compose.local.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Local development compose file.
# Builds the Logto image from the local source tree (including any uncommitted
# changes) instead of pulling the published `svhd/logto` image.
#
# Usage (from the repo root):
# docker compose -f docker-compose.local.yml up --build
#
# Then open:
# http://localhost:3001 -> Logto core / auth endpoint
# http://localhost:3002 -> Admin Console (Webhooks UI, etc.)
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
# Enable in-development features (useful while iterating locally).
dev_features_enabled: "true"
depends_on:
postgres:
condition: service_healthy
entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"]
ports:
- "3001:3001"
- "3002:3002"
environment:
TRUST_PROXY_HEADER: "1"
DB_URL: postgres://postgres:p0stgr3s@postgres:5432/logto
ENDPOINT: http://localhost:3001
ADMIN_ENDPOINT: http://localhost:3002
restart: unless-stopped

postgres:
image: postgres:17-alpine
user: postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: p0stgr3s
volumes:
- logto-postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped

volumes:
logto-postgres-data:
10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"preinstall": "npx only-allow pnpm",
"pnpm:devPreinstall": "cd packages/connectors && node templates/sync-preset.js",
"prepare": "if test \"$NODE_ENV\" != \"production\" && test \"$CI\" != \"true\" ; then husky ; fi",
"prepare": "",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🏗️ [Medium]: Blanking the root prepare script turns a Docker-specific workaround into a repo-wide install contract change and removes the shared lifecycle hook future local bootstrap steps would need.

"prepack": "pnpm -r prepack",
"dev": "pnpm -r prepack && pnpm start:dev",
"dev:cloud": "IS_CLOUD=1 CONSOLE_PUBLIC_URL=/ pnpm dev",
Expand Down Expand Up @@ -80,7 +80,13 @@
},
"patchedDependencies": {
"tsup": "patches/tsup.patch"
}
},
"onlyBuiltDependencies": [
"@swc/core",
"core-js",
"esbuild",
"puppeteer"
]
},
"dependencies": {
"@logto/cli": "workspace:^",
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/libraries/hook/context-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
InteractionHookEvent,
type InteractionHookEventPayload,
type User,
type Role,
type OrganizationRole,
managementApiHooksRegistration,
type DataHookEvent,
type InteractionApiMetadata,
Expand Down Expand Up @@ -50,9 +52,18 @@ type UserContext = {
*/
type DataHookContextMap = {
'Organization.Membership.Updated': { organizationId: string };
'Organization.UserRoles.Updated': UserContext & {
organizationId: string;
/** The organization roles currently assigned to the user after the mutation. */
organizationRoles: OrganizationRole[];
};
'User.Created': UserContext;
'User.Data.Updated': UserContext;
'User.Deleted': UserContext;
'User.Roles.Updated': UserContext & {
/** The roles currently assigned to the user after the mutation. */
roles: Role[];
};
};

export class HookContextManager {
Expand Down
43 changes: 39 additions & 4 deletions packages/core/src/routes/admin-user/role.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { tryThat } from '@silverhand/essentials';
import { array, object, string } from 'zod';

import RequestError from '#src/errors/RequestError/index.js';
import { buildManagementApiContext } from '#src/libraries/hook/utils.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import koaRoleRlsErrorHandler from '#src/middleware/koa-role-rls-error-handler.js';
Expand All @@ -21,6 +22,18 @@ export default function adminUserRoleRoutes<T extends ManagementApiRouter>(
usersRoles: { deleteUsersRolesByUserIdAndRoleId, findUsersRolesByUserId, insertUsersRoles },
} = queries;

// Fetch the full user and the current role set so downstream webhooks receive rich payloads.
const buildUserRolesHookContext = async (userId: string) => {
const [user, usersRoles] = await Promise.all([
findUserById(userId),
findUsersRolesByUserId(userId),
]);
const roleIds = usersRoles.map(({ roleId }) => roleId);
const roles = roleIds.length > 0 ? await findRolesByRoleIds(roleIds) : [];

return { user, roles };
};

router.use('/users/:userId/roles(/.*)?', koaRoleRlsErrorHandler());

router.get(
Expand Down Expand Up @@ -48,7 +61,6 @@ export default function adminUserRoleRoutes<T extends ManagementApiRouter>(
findRoles(search, limit, offset, { roleIds, type: RoleType.User }),
]);

// Return totalCount to pagination middleware
ctx.pagination.totalCount = count;
ctx.body = roles;

Expand Down Expand Up @@ -87,7 +99,7 @@ export default function adminUserRoleRoutes<T extends ManagementApiRouter>(
await findUserById(userId);
const usersRoles = await findUsersRolesByUserId(userId);
const existingRoleIds = new Set(usersRoles.map(({ roleId }) => roleId));
const roleIdsToAdd = roleIds.filter((id) => !existingRoleIds.has(id)); // ignore existing roles.
const roleIdsToAdd = roleIds.filter((id) => !existingRoleIds.has(id));
const roles = await findRolesByRoleIds(roleIdsToAdd);

for (const role of roles) {
Expand All @@ -107,6 +119,14 @@ export default function adminUserRoleRoutes<T extends ManagementApiRouter>(
ctx.body = { roleIds, addedRoleIds: roleIdsToAdd };
ctx.status = 201;

const { user, roles: currentRoles } = await buildUserRolesHookContext(userId);
ctx.appendDataHookContext('User.Roles.Updated', {
...buildManagementApiContext(ctx),
user,
roles: currentRoles,
addedRoleIds: roleIdsToAdd,
});

return next();
}
);
Expand All @@ -128,11 +148,9 @@ export default function adminUserRoleRoutes<T extends ManagementApiRouter>(
await findUserById(userId);
const usersRoles = await findUsersRolesByUserId(userId);

// Only add the ones that doesn't exist
const roleIdsToAdd = roleIds.filter(
(roleId) => !usersRoles.some(({ roleId: _roleId }) => _roleId === roleId)
);
// Remove existing roles that isn't wanted by user anymore
const roleIdsToRemove = usersRoles
.filter(({ roleId }) => !roleIds.includes(roleId))
.map(({ roleId }) => roleId);
Expand All @@ -148,6 +166,15 @@ export default function adminUserRoleRoutes<T extends ManagementApiRouter>(
ctx.body = { roleIds: [...new Set(roleIds)] };
ctx.status = 200;

const { user, roles: currentRoles } = await buildUserRolesHookContext(userId);
ctx.appendDataHookContext('User.Roles.Updated', {
...buildManagementApiContext(ctx),
user,
roles: currentRoles,
addedRoleIds: roleIdsToAdd,
removedRoleIds: roleIdsToRemove,
});

return next();
}
);
Expand All @@ -167,6 +194,14 @@ export default function adminUserRoleRoutes<T extends ManagementApiRouter>(

ctx.status = 204;

const { user, roles: currentRoles } = await buildUserRolesHookContext(userId);
ctx.appendDataHookContext('User.Roles.Updated', {
...buildManagementApiContext(ctx),
user,
roles: currentRoles,
removedRoleIds: [roleId],
});

return next();
}
);
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/routes/organization/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export default function organizationRoutes<T extends ManagementApiRouter>(
originalRouter,
{
id: tenantId,
queries,
queries: { organizations },
libraries: { quota },
},
Expand Down Expand Up @@ -107,7 +108,7 @@ export default function organizationRoutes<T extends ManagementApiRouter>(
}
);

userRoutes(router, organizations, quota);
userRoutes(router, organizations, quota, queries);
applicationRoutes(router, organizations);
jitRoutes(router, organizations);

Expand Down
33 changes: 31 additions & 2 deletions packages/core/src/routes/organization/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
type OrganizationKeys,
type CreateOrganization,
type Organization,
OrganizationRoles,
userWithOrganizationRolesGuard,
Users,
} from '@logto/schemas';
Expand All @@ -13,6 +14,7 @@ import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import type OrganizationQueries from '#src/queries/organization/index.js';
import { userSearchKeys } from '#src/queries/user.js';
import type Queries from '#src/tenants/Queries.js';
import type SchemaRouter from '#src/utils/SchemaRouter.js';
import { parseSearchOptions } from '#src/utils/search.js';

Expand All @@ -22,8 +24,12 @@ import userRoleRelationRoutes from './role-relations.js';
export default function userRoutes(
router: SchemaRouter<OrganizationKeys, CreateOrganization, Organization>,
organizations: OrganizationQueries,
quota: QuotaLibrary
quota: QuotaLibrary,
queries: Queries
) {
const {
users: { findUserById },
} = queries;
router.get(
'/:id/users',
koaPagination(),
Expand Down Expand Up @@ -173,9 +179,32 @@ export default function userRoutes(
);

ctx.status = 201;

// Emit one `Organization.UserRoles.Updated` event per affected user so each payload
// contains the full user + role set for that user in this organization.
await Promise.all(
userIds.map(async (userId) => {
const [user, [, organizationRoles]] = await Promise.all([
findUserById(userId),
organizations.relations.usersRoles.getEntities(OrganizationRoles, {
organizationId: id,
userId,
}),
]);

ctx.appendDataHookContext('Organization.UserRoles.Updated', {
...buildManagementApiContext(ctx),
organizationId: id,
user,
organizationRoles,
addedOrganizationRoleIds: organizationRoleIds,
});
})
);

return next();
}
);

userRoleRelationRoutes(router, organizations);
userRoleRelationRoutes(router, organizations, queries);
}
Loading
Loading