Skip to content

feat: add per-member server access control#3933

Open
lcaputo wants to merge 8 commits intoDokploy:canaryfrom
lcaputo:feature/server-access-permissions
Open

feat: add per-member server access control#3933
lcaputo wants to merge 8 commits intoDokploy:canaryfrom
lcaputo:feature/server-access-permissions

Conversation

@lcaputo
Copy link

@lcaputo lcaputo commented Mar 8, 2026

What is this PR about?

This PR introduces server-level access control for members.

Changes

  • Adds accessedServers field to the member permissions model

  • Integrates server selection as a multi-select dropdown in the permissions form

  • Enforces canAccessServer guards on all service creation routes:

    • application
    • compose
    • postgres
    • mysql
    • redis
    • mongo
    • mariadb
  • Tested locally: members without server access are blocked from creating services on restricted servers. Multi-select correctly persists and loads server selections.

Checklist

Before submitting this PR, please make sure that:

  • You created a dedicated branch based on the canary branch.
  • You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
  • You have tested this PR in your local instance. If you have not tested it yet, please do so before submitting. This helps avoid wasting maintainers' time reviewing code that has not been verified by you.

Issues related (if applicable)

Screenshots (if applicable)

image

Greptile Summary

This PR adds per-member server access control to Dokploy. It introduces an accessedServers field to the member schema (with migration), a canAccessServer service function, server filtering on API endpoints, and canAccessServer guards on all service creation routes. The permissions UI is extended with a multi-select combobox to assign server access to members.

The backend security enforcement is solid. However, two UI-layer issues remain:

  1. !isCloud undefined guard in permissions form: When isCloud is still loading (undefined), !isCloud evaluates to true, temporarily including "local" in allServerIds and rendering the "Dokploy" option even on cloud deployments. An admin who clicks "Select All" before the query resolves would inadvertently save "local" into the member's accessedServers. Fix: use isCloud === false instead of !isCloud.

  2. canUseLocalServer flash during loading: In add-application.tsx, add-compose.tsx, add-database.tsx, add-template.tsx, and ai/step-one.tsx, the "Dokploy" option briefly appears for members without local access due to isLoadingMember being the first condition. Since !currentMember already covers the loading case, removing isLoadingMember || eliminates the flash entirely.

Confidence Score: 3/5

  • Safe to merge on self-hosted; cloud deployments have a minor race condition in the permissions UI that should be fixed first.
  • Backend security is correctly enforced across all create routes and the migration is present. The two issues are UI-layer concerns: a brief loading-state flash in the service create forms (cosmetic), and an !isCloud undefined guard in the permissions form that could temporarily include "local" as an option on cloud deployments. The !isCloud issue is more serious as it affects cloud deployments and could persist data, warranting a fix before deploying to cloud instances.
  • apps/dokploy/components/dashboard/settings/users/add-permissions.tsx (isCloud undefined guard), apps/dokploy/components/dashboard/project/add-application.tsx and sibling files (isLoadingMember flash)

Comments Outside Diff (11)

  1. apps/dokploy/components/dashboard/project/add-application.tsx, line 212-215 (link)

    defaultValue race condition for restricted members

    currentMember is undefined while api.user.get is still loading, which makes canUseLocalServer = true on the first render. For a member without local-server access, this means defaultValue="dokploy" is passed to <Select> on mount. Because Radix UI's Select treats defaultValue as an uncontrolled initial state, the component retains "Dokploy" as its displayed selection even after the query resolves and the <SelectItem value="dokploy"> is removed from the DOM.

    The actual field.value remains undefined (since defaultValue never fires onChange), so a form submission sends serverId: undefined which the backend resolves as "local" and then correctly rejects — but the user sees a confusing error rather than a blocked submit button.

    The same pattern is repeated in add-compose.tsx, add-database.tsx, add-template.tsx, and ai/step-one.tsx.

    A minimal fix is to defer setting defaultValue until the query has settled:

    This way, while currentMember is still loading the local-server option is simply absent (no spurious pre-selection), and once the data arrives the correct value is applied.

  2. apps/dokploy/server/api/routers/server.ts, line 131-158 (link)

    buildServers not filtered by accessedServers

    server.all (line 90) and server.withSSHKey (line 131) now both filter results by accessedServers for members, but server.buildServers (line 159) returns all build servers without any such filtering. If a member has no access to a given build server they should still not be able to see or select it during service creation. Unless build-server assignment is intentionally admin-only and members never interact with this list, the same guard should be applied here:

    if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
        const memberData = await findMemberById(ctx.user.id, ctx.session.activeOrganizationId);
        const allowed = new Set(memberData.accessedServers ?? []);
        return result.filter((s) => allowed.has(s.serverId));
    }
    
  3. apps/dokploy/components/dashboard/settings/users/add-permissions.tsx, line 935-939 (link)

    Server badge removal breaks with duplicate server names

    The badge X button resolves a server's serverId by reverse-searching allServers by name:

    const id =
      label === localLabel
        ? "local"
        : (allServers?.find((s) => s.name === label)?.serverId ?? "");
    toggleServer(id);

    Two issues here:

    1. If two remote servers share the same display name, Array.find returns the first match — clicking X on the second server's badge silently removes the first server's ID instead.
    2. If the lookup fails for any reason (find returns undefined), id becomes "" and toggleServer("") will append the empty string to accessedServers rather than removing the intended entry.

    Consider building allLabels as { label: string; id: string }[] so the id is captured at badge-render time rather than re-derived from the display name on click. This avoids the reverse-lookup entirely.

  4. apps/dokploy/components/dashboard/project/add-application.tsx, line 79-83 (link)

    Loading state causes Select default to initialize as undefined

    canUseLocalServer evaluates to false while isLoadingMember is true because of the leading !isLoadingMember && guard. Since Radix UI's Select reads defaultValue only on initial mount, when the member query resolves and canUseLocalServer flips to true, the defaultValue prop change will not retroactively update the already-mounted component. This means owners and admins on self-hosted (non-cloud) installations will see the "Select a Server" placeholder instead of "Dokploy" being pre-selected — a regression from the pre-PR behaviour.

    The correct fix is to be optimistic during the loading state (the server-side guard is the real enforcement):

    This same pattern is repeated in:

    • add-compose.tsx (lines 80–84)
    • add-database.tsx (lines 186–190)
    • add-template.tsx (lines 121–125)
    • ai/step-one.tsx (lines 31–35)

    All five files need this update.

  5. apps/dokploy/server/api/routers/server.ts, line 112-117 (link)

    Redundant findMemberById calls across server listing queries

    all, withSSHKey, and buildServers each independently call findMemberById for non-admin users (lines 112–117, 152–157, 180–185). When a page load triggers multiple of these queries in parallel—for example, in apps/dokploy/components/dashboard/settings/cluster/registry/handle-registry.tsx which calls both withSSHKey and buildServers on mount—this results in duplicate round-trips to fetch the same member row.

    The same redundancy was already identified and fixed for the service creation routers (where checkServiceAccess now returns the member, which is then passed to canAccessServer). The same fix should be applied here—either by sharing a single findMemberById call across these three handlers, or by pulling the filtering into a shared helper that accepts an optional pre-fetched member.

    Example refactor approach:

    // At the start of the protectedProcedure callback
    const memberData =
      ctx.user.role === "owner" || ctx.user.role === "admin"
        ? null
        : await findMemberById(ctx.user.id, ctx.session.activeOrganizationId);
    const allowed = memberData ? new Set(memberData.accessedServers ?? []) : null;
    
    // Then each query:
    return allowed ? result.filter((s) => allowed.has(s.serverId)) : result;

    Apply the same pattern to withSSHKey (lines 152–157) and buildServers (lines 180–185).

  6. apps/dokploy/components/dashboard/settings/users/add-permissions.tsx, line 885-893 (link)

    !isCloud evaluates to true while the query is loading (when isCloud is undefined). This means "local" is temporarily included in allServerIds on cloud deployments. If an admin clicks "Select All" before the query resolves, "local" persists in the member's accessedServers.

    The same issue appears at line 968 with the {!isCloud && ( conditional that renders the "Dokploy" option.

    Fix by using isCloud === false to treat the loading state as cloud-safe by default:

    Also update line 968:

  7. apps/dokploy/components/dashboard/project/add-application.tsx, line 79-83 (link)

    The "Dokploy" option briefly flashes during the initial member data load. canUseLocalServer evaluates to true when isLoadingMember is true due to the first OR condition, even for members without local access. When loading completes, the key prop triggers a remount and the option disappears.

    Since !currentMember already handles the undefined case, isLoadingMember is redundant. Removing it eliminates the flash:

    The same pattern applies in add-compose.tsx, add-database.tsx, add-template.tsx, and ai/step-one.tsx.

  8. apps/dokploy/components/dashboard/project/add-compose.tsx, line 80-84 (link)

    Same issue as in add-application.tsx: remove the redundant isLoadingMember || condition.

  9. apps/dokploy/components/dashboard/project/add-database.tsx, line 186-190 (link)

    Same issue as in add-application.tsx: remove the redundant isLoadingMember || condition.

  10. apps/dokploy/components/dashboard/project/add-template.tsx, line 121-125 (link)

    Same issue as in add-application.tsx: remove the redundant isLoadingMember || condition.

  11. apps/dokploy/components/dashboard/project/ai/step-one.tsx, line 31-35 (link)

    Same issue as in add-application.tsx: remove the redundant isLoadingMember || condition.

Last reviewed commit: 749d990

Context used:

  • Rule used - AGENTS.md (source)

@lcaputo lcaputo requested a review from Siumauricio as a code owner March 8, 2026 04:12
@dosubot dosubot bot added size:XL This PR changes 500-999 lines, ignoring generated files. enhancement New feature or request labels Mar 8, 2026
lcaputo added 2 commits March 7, 2026 23:47
- Removed the updateMemberServers

- Global press animation restored to buttonVariants. The combobox button in add-permissions.tsx:921 already has active:scale-100 active:translate-y-0 which overrides the global styles specifically for that button

- Refactor checkServiceAccess to fetch the member once internally and return it
- Make canAccessServer accept an optional pre-fetched member
- Update the 8 "create" call sites in routers to pass the member through
@lcaputo lcaputo marked this pull request as draft March 8, 2026 15:46
@lcaputo lcaputo marked this pull request as ready for review March 8, 2026 15:46
@lcaputo lcaputo marked this pull request as draft March 9, 2026 13:49
@lcaputo lcaputo marked this pull request as ready for review March 9, 2026 22:29
@lcaputo lcaputo marked this pull request as draft March 9, 2026 23:09
@lcaputo lcaputo marked this pull request as ready for review March 9, 2026 23:12
@lcaputo
Copy link
Author

lcaputo commented Mar 10, 2026

@greptile review

@lcaputo
Copy link
Author

lcaputo commented Mar 10, 2026

@greptile review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant