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:
@@ -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 };
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user