feat: implement RBAC permissions helper (Phase 2.3)

Add comprehensive RBAC permission checking using Kubernetes
SelfSubjectAccessReview API. Hide/disable UI elements based on
user permissions for better security and UX.

Features:
- RBAC module with permission checking utilities
- React hooks for permission management (usePermissions, usePermission, etc.)
- Permission-aware UI (hide create/delete/re-encrypt buttons)
- Decrypt button disabled if no Secret access
- Multi-namespace permission support
- Fail-safe design (returns false on error)

Technical details:
- Uses Kubernetes authorization.k8s.io/v1 SelfSubjectAccessReview API
- Concurrent permission checks with Promise.all
- Automatic loading states and error handling
- React cleanup on unmount prevents memory leaks
- Type-safe with Result<T, E> types

Files:
- src/lib/rbac.ts: NEW RBAC checking module (+168 lines)
- src/hooks/usePermissions.ts: NEW React hooks (+138 lines)
- src/components/SealedSecretList.tsx: Hide create button if no permission
- src/components/SealedSecretDetail.tsx: Hide re-encrypt/delete/decrypt based on permissions
- PHASE_2.3_COMPLETE.md: Implementation documentation
- .claude/agents/: Add 5 new specialized agents (test, accessibility, docs, orchestration)

Bundle size: 348.46 kB (96.05 kB gzipped), +1.81 kB (+0.5%)
Build time: 3.93s
Zero TypeScript/lint errors

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
2026-02-11 21:51:05 -05:00
parent d17e2485fb
commit 839fdd4819
12 changed files with 2017 additions and 30 deletions
@@ -17,7 +17,9 @@ import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle } from '
import { useSnackbar } from 'notistack';
import React from 'react';
import { useParams } from 'react-router-dom';
import { usePermissions } from '../hooks/usePermissions';
import { getPluginConfig, rotateSealedSecret } from '../lib/controller';
import { canDecryptSecrets } from '../lib/rbac';
import { SealedSecret } from '../lib/SealedSecretCRD';
import { SealedSecretScope } from '../types';
import { DecryptDialog } from './DecryptDialog';
@@ -48,7 +50,16 @@ export function SealedSecretDetail() {
const [decryptKey, setDecryptKey] = React.useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
const [rotating, setRotating] = React.useState(false);
const [canDecrypt, setCanDecrypt] = React.useState(false);
const { enqueueSnackbar } = useSnackbar();
const { permissions } = usePermissions(namespace);
// Check if user can decrypt secrets (requires get permission on Secrets)
React.useEffect(() => {
if (namespace) {
canDecryptSecrets(namespace).then(setCanDecrypt);
}
}, [namespace]);
if (!sealedSecret) {
return <Loader title="Loading SealedSecret..." />;
@@ -90,21 +101,25 @@ export function SealedSecretDetail() {
<Box display="flex" alignItems="center" justifyContent="space-between">
<span>{sealedSecret.metadata.name}</span>
<Box>
<Button
variant="outlined"
onClick={handleRotate}
disabled={rotating}
sx={{ mr: 1 }}
>
{rotating ? 'Re-encrypting...' : 'Re-encrypt'}
</Button>
<Button
variant="outlined"
color="error"
onClick={() => setDeleteDialogOpen(true)}
>
Delete
</Button>
{permissions?.canUpdate && (
<Button
variant="outlined"
onClick={handleRotate}
disabled={rotating}
sx={{ mr: 1 }}
>
{rotating ? 'Re-encrypting...' : 'Re-encrypt'}
</Button>
)}
{permissions?.canDelete && (
<Button
variant="outlined"
color="error"
onClick={() => setDeleteDialogOpen(true)}
>
Delete
</Button>
)}
</Box>
</Box>
}
@@ -168,11 +183,16 @@ export function SealedSecretDetail() {
},
{
label: 'Actions',
getter: (row: any) => (
<Button size="small" onClick={() => setDecryptKey(row.key)}>
Decrypt
</Button>
),
getter: (row: any) =>
canDecrypt ? (
<Button size="small" onClick={() => setDecryptKey(row.key)}>
Decrypt
</Button>
) : (
<Button size="small" disabled title="No permission to access Secrets">
Decrypt
</Button>
),
},
]}
/>
@@ -13,6 +13,7 @@ import {
} from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { Box, Button } from '@mui/material';
import React from 'react';
import { usePermission } from '../hooks/usePermissions';
import { SealedSecret } from '../lib/SealedSecretCRD';
import { SealedSecretScope } from '../types';
import { EncryptDialog } from './EncryptDialog';
@@ -39,6 +40,7 @@ function formatScope(scope: SealedSecretScope): string {
export function SealedSecretList() {
const [sealedSecrets, error] = SealedSecret.useList();
const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
const { allowed: canCreate } = usePermission(undefined, 'canCreate');
// Show error if CRD is not installed
if (error) {
@@ -76,16 +78,20 @@ export function SealedSecretList() {
<SectionFilterHeader
title=""
noNamespaceFilter={false}
actions={[
<Button
key="create"
variant="contained"
color="primary"
onClick={() => setCreateDialogOpen(true)}
>
Create Sealed Secret
</Button>,
]}
actions={
canCreate
? [
<Button
key="create"
variant="contained"
color="primary"
onClick={() => setCreateDialogOpen(true)}
>
Create Sealed Secret
</Button>,
]
: []
}
/>
<SimpleTable
data={sealedSecrets}
@@ -0,0 +1,137 @@
/**
* React Hooks for RBAC Permission Checking
*
* Provides React hooks for checking and caching user permissions
* for SealedSecrets and related resources.
*/
import React from 'react';
import { checkSealedSecretPermissions, ResourcePermissions } from '../lib/rbac';
/**
* Hook to check SealedSecret permissions for a namespace
*
* Automatically fetches permissions on mount and when namespace changes.
* Returns loading state and permissions.
*
* @param namespace Optional namespace to check (cluster-wide if omitted)
* @returns Object with loading state, permissions, and error
*
* @example
* const { loading, permissions, error } = usePermissions('default');
* if (!loading && permissions?.canCreate) {
* // Show create button
* }
*/
export function usePermissions(namespace?: string) {
const [loading, setLoading] = React.useState(true);
const [permissions, setPermissions] = React.useState<ResourcePermissions | null>(null);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let mounted = true;
async function fetchPermissions() {
setLoading(true);
setError(null);
const result = await checkSealedSecretPermissions(namespace);
if (!mounted) return;
if (result.ok) {
setPermissions(result.value);
setError(null);
} else if (result.ok === false) {
setPermissions(null);
setError(result.error);
}
setLoading(false);
}
fetchPermissions();
return () => {
mounted = false;
};
}, [namespace]);
return { loading, permissions, error };
}
/**
* Hook to check a specific permission
*
* Useful when you only need to check one permission (e.g., canCreate)
* instead of fetching all permissions.
*
* @param namespace Optional namespace to check
* @param permission Permission key to check
* @returns Object with loading state and allowed flag
*
* @example
* const { loading, allowed } = usePermission('default', 'canCreate');
* if (allowed) {
* // Show create button
* }
*/
export function usePermission(
namespace: string | undefined,
permission: keyof ResourcePermissions
) {
const { loading, permissions } = usePermissions(namespace);
const allowed = permissions?.[permission] ?? false;
return { loading, allowed };
}
/**
* Hook to check if user has any write permissions
*
* Returns true if user can create, update, or delete.
* Useful for showing/hiding entire sections of UI.
*
* @param namespace Optional namespace to check
* @returns Object with loading state and hasWriteAccess flag
*
* @example
* const { loading, hasWriteAccess } = useHasWriteAccess('default');
* if (hasWriteAccess) {
* // Show management UI
* }
*/
export function useHasWriteAccess(namespace?: string) {
const { loading, permissions } = usePermissions(namespace);
const hasWriteAccess =
permissions?.canCreate || permissions?.canUpdate || permissions?.canDelete || false;
return { loading, hasWriteAccess };
}
/**
* Hook to check if user has read-only access
*
* Returns true if user can read/list but cannot create/update/delete.
*
* @param namespace Optional namespace to check
* @returns Object with loading state and isReadOnly flag
*
* @example
* const { loading, isReadOnly } = useIsReadOnly('default');
* if (isReadOnly) {
* // Show read-only warning
* }
*/
export function useIsReadOnly(namespace?: string) {
const { loading, permissions } = usePermissions(namespace);
const isReadOnly =
(permissions?.canRead || permissions?.canList) &&
!permissions?.canCreate &&
!permissions?.canUpdate &&
!permissions?.canDelete;
return { loading, isReadOnly };
}
+165
View File
@@ -0,0 +1,165 @@
/**
* RBAC Permission Checking
*
* Utilities for checking user permissions for SealedSecrets and related
* Kubernetes resources using SelfSubjectAccessReview API.
*/
import { AsyncResult, Err, Ok, tryCatchAsync } from '../types';
/**
* Resource permissions for a specific resource type
*/
export interface ResourcePermissions {
/** Can create new resources */
canCreate: boolean;
/** Can read/get individual resources */
canRead: boolean;
/** Can update/patch existing resources */
canUpdate: boolean;
/** Can delete resources */
canDelete: boolean;
/** Can list resources */
canList: boolean;
}
/**
* Check user permissions for SealedSecrets in a namespace
*
* Uses Kubernetes SelfSubjectAccessReview API to verify what the current
* user is allowed to do with SealedSecret resources.
*
* @param namespace Optional namespace to check (cluster-wide if omitted)
* @returns Result containing permission flags or error message
*/
export async function checkSealedSecretPermissions(
namespace?: string
): AsyncResult<ResourcePermissions, string> {
try {
const [canCreate, canRead, canUpdate, canDelete, canList] = await Promise.all([
checkPermission('create', 'sealedsecrets', 'bitnami.com', namespace),
checkPermission('get', 'sealedsecrets', 'bitnami.com', namespace),
checkPermission('update', 'sealedsecrets', 'bitnami.com', namespace),
checkPermission('delete', 'sealedsecrets', 'bitnami.com', namespace),
checkPermission('list', 'sealedsecrets', 'bitnami.com', namespace),
]);
return Ok({
canCreate,
canRead,
canUpdate,
canDelete,
canList,
});
} catch (error: any) {
return Err(`Failed to check SealedSecret permissions: ${error.message}`);
}
}
/**
* Check if user can decrypt secrets (requires get permission on Secrets)
*
* @param namespace Namespace to check Secret permissions in
* @returns true if user has permission to get Secrets
*/
export async function canDecryptSecrets(namespace: string): Promise<boolean> {
try {
return await checkPermission('get', 'secrets', '', namespace);
} catch {
return false;
}
}
/**
* Check if user can view sealing keys (requires get permission on Secrets in controller namespace)
*
* @param controllerNamespace Namespace where sealed-secrets controller is running
* @returns true if user has permission to get Secrets in controller namespace
*/
export async function canViewSealingKeys(controllerNamespace: string): Promise<boolean> {
try {
return await checkPermission('get', 'secrets', '', controllerNamespace);
} catch {
return false;
}
}
/**
* Check a specific permission using SelfSubjectAccessReview
*
* @param verb Kubernetes verb (create, get, update, delete, list, etc.)
* @param resource Resource type (sealedsecrets, secrets, etc.)
* @param group API group (bitnami.com for SealedSecrets, empty for core resources)
* @param namespace Optional namespace (cluster-wide if omitted)
* @returns true if user has permission, false otherwise
*/
async function checkPermission(
verb: string,
resource: string,
group: string,
namespace?: string
): Promise<boolean> {
const result = await tryCatchAsync(async () => {
const reviewRequest = {
apiVersion: 'authorization.k8s.io/v1',
kind: 'SelfSubjectAccessReview',
spec: {
resourceAttributes: {
...(group && { group }),
resource,
verb,
...(namespace && { namespace }),
},
},
};
const response = await fetch('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reviewRequest),
});
if (!response.ok) {
throw new Error(`RBAC check failed: ${response.status} ${response.statusText}`);
}
const result = await response.json();
return result.status?.allowed === true;
});
// Return false on error (assume no permission)
return result.ok ? result.value : false;
}
/**
* Check permissions for multiple namespaces
*
* Useful for multi-namespace views to determine which namespaces the user
* can interact with.
*
* @param namespaces Array of namespace names to check
* @returns Map of namespace to permissions
*/
export async function checkMultiNamespacePermissions(
namespaces: string[]
): AsyncResult<Record<string, ResourcePermissions>, string> {
try {
const results = await Promise.all(
namespaces.map(async ns => {
const perms = await checkSealedSecretPermissions(ns);
return { namespace: ns, permissions: perms };
})
);
const permissionsMap: Record<string, ResourcePermissions> = {};
for (const { namespace, permissions } of results) {
if (permissions.ok) {
permissionsMap[namespace] = permissions.value;
}
}
return Ok(permissionsMap);
} catch (error: any) {
return Err(`Failed to check multi-namespace permissions: ${error.message}`);
}
}