Skip to content
Merged
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
2 changes: 1 addition & 1 deletion dashboard/pkg/epinio/list/catalogservices.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const resource: string = EPINIO_TYPES.CATALOG_SERVICE;
const canEdit = computed(() => {
const can = store.getters['epinio/can'];

return can && (can('service_write'));
return can && (can('catalog_service_write') || can('catalog_service'));
});
const canDelete = canEdit;
const canCreate = canEdit;
Expand Down
15 changes: 14 additions & 1 deletion dashboard/pkg/epinio/list/namespaces.vue
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,22 @@ const canCreateNamespace = computed(() => {
return false;
}

// Create is cluster-scoped: gate on the global-only namespace_create.
return can('namespace_create');
});
// Per-namespace delete is namespaced (server authorizes it against the role for
// the namespace being deleted), so it stays on the flat namespace_write and is
// further gated per-row by row.canDelete below.
const canDelete = computed(() => {
const can = store.getters['epinio/can'];
const perms = store.getters['epinio/permissions']?.();

if (!can || !perms || Object.keys(perms).length === 0) {
return false;
}

return can('namespace_write') || can('namespace');
});
const canDelete = canCreateNamespace;

watchEffect(() => {
const all = store.getters['epinio/all'](EPINIO_TYPES.NAMESPACE) as EpinioNamespace[];
Expand Down
48 changes: 45 additions & 3 deletions dashboard/pkg/epinio/utils/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,17 @@ const ROLE_ACTIONS: Record<string, string[]> = {
// Actions only the admin role has (server-side admin can create/delete namespaces; other roles cannot).
const ADMIN_ONLY_ACTIONS = ['namespace_write', 'namespace'];

// Actions the server authorizes against GLOBAL (namespace-less) roles only,
// because they act on cluster-scoped resources whose routes carry no :namespace:
// app charts (/appcharts), builder images (/builderimages), git configs
// (/gitconfigs). Server-side, User.IsAllowed resolves these against global roles,
// so a namespace-scoped role must NOT grant them. Without this, the default
// "epinio" user's admin:workspace role flattens into the global map and the UI
// shows a Create button the server then 403s. This mirrors the isAdmin getter's
// fix. NB: the git-config "global" checkbox is a separate admin-only concern,
// already gated by isAdmin in GitConfigModal.vue.
const CLUSTER_SCOPED_ACTIONS = ['chart_write', 'builderimage_write', 'gitconfig_write'];

// Union of all actions for the admin role – effectively "everything".
const ADMIN_ACTIONS = Array.from(
new Set<string>([...Object.values(ROLE_ACTIONS).flat(), ...ADMIN_ONLY_ACTIONS]),
Expand All @@ -158,25 +169,36 @@ function normalizeRoleId(id: string): string {
*/
export function buildPermissionsFromRoles(roles: EpinioRole[]): EpinioPermissions {
const actions = new Set<string>();
// Actions granted specifically by global (namespace-less) roles. Cluster-scoped
// actions are sourced from here so a namespaced role cannot grant them.
const globalActions = new Set<string>();

for (const role of roles || []) {
const roleId = normalizeRoleId(role.id || '');
const isGlobal = !role.namespace;

const add = (a: string) => {
actions.add(a);
if (isGlobal) {
globalActions.add(a);
}
};

if (roleId === 'admin') {
ADMIN_ACTIONS.forEach((a) => actions.add(a));
ADMIN_ACTIONS.forEach(add);
continue;
}

// Prefer actions returned by the server (/api/v1/me). Fall back to the
// hardcoded ROLE_ACTIONS map only when the server didn't send any —
// i.e. older Epinio versions that don't include role.actions.
if (Array.isArray(role.actions) && role.actions.length > 0) {
role.actions.forEach((a) => actions.add(a));
role.actions.forEach(add);
continue;
}

const mapped = ROLE_ACTIONS[roleId] || [];
mapped.forEach((a) => actions.add(a));
mapped.forEach(add);
}

const perms: EpinioPermissions = {};
Expand All @@ -185,6 +207,26 @@ export function buildPermissionsFromRoles(roles: EpinioRole[]): EpinioPermission
perms[a] = true;
});

// Cluster-scoped actions are valid only when a global role granted them.
// Override any that leaked in via a namespaced role (e.g. admin:workspace).
CLUSTER_SCOPED_ACTIONS.forEach((a) => {
perms[a] = globalActions.has(a);
});

// Catalog services are cluster-scoped too, but the server authorizes them via
// the mixed service_write action (which also grants namespaced service-instance
// writes) rather than a dedicated one. Derive the UI-only catalog_service_write
// from GLOBAL service_write so a namespaced service_write cannot grant catalog
// management. The catalog list/detail components gate on catalog_service_write.
perms.catalog_service_write = globalActions.has('service_write');

// namespace_write is also mixed: NamespaceCreate (POST /namespaces) is
// cluster-scoped, but per-namespace delete (DELETE /namespaces/:namespace) is
// namespaced. Derive a global-only create permission so a namespaced admin does
// not see the create-namespace button; per-namespace delete stays on the flat
// namespace_write. The namespaces list gates create on namespace_create.
perms.namespace_create = globalActions.has('namespace_write');

return perms;
}

Loading