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
18 changes: 15 additions & 3 deletions packages/openchoreo-client-node/openapi/openchoreo-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1441,16 +1441,16 @@ components:
RoleEntitlementMapping:
type: object
required:
- role_name
- role
- entitlement
- hierarchy
- effect
properties:
id:
type: integer
description: Unique identifier for the mapping
role_name:
type: string
role:
$ref: '#/components/schemas/RoleRef'
entitlement:
$ref: '#/components/schemas/Entitlement'
hierarchy:
Expand All @@ -1460,6 +1460,18 @@ components:
context:
type: object

RoleRef:
type: object
required:
- name
properties:
name:
type: string
description: Name of the role
namespace:
type: string
description: Optional namespace for the role

UpdateRoleRequest:
type: object
required:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1559,12 +1559,18 @@ export interface components {
RoleEntitlementMapping: {
/** @description Unique identifier for the mapping */
id?: number;
role_name: string;
role: components['schemas']['RoleRef'];
entitlement: components['schemas']['Entitlement'];
hierarchy: components['schemas']['AuthzResourceHierarchy'];
effect: components['schemas']['PolicyEffectType'];
context?: Record<string, never>;
};
RoleRef: {
/** @description Name of the role */
name: string;
/** @description Optional namespace for the role */
namespace?: string;
};
UpdateRoleRequest: {
/** @description List of actions to assign to the role */
actions: string[];
Expand Down
8 changes: 4 additions & 4 deletions plugins/openchoreo-backend/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -804,9 +804,9 @@ export async function createRouter({

router.post('/authz/role-mappings', requireAuth, async (req, res) => {
const mapping = req.body;
if (!mapping || !mapping.role_name || !mapping.entitlement) {
if (!mapping || !mapping.role.name || !mapping.entitlement) {
throw new InputError(
'Mapping must have role_name and entitlement fields',
'Mapping must have role name and entitlement fields',
);
}
const userToken = getUserTokenFromRequest(req);
Expand All @@ -822,9 +822,9 @@ export async function createRouter({
throw new InputError('Invalid mapping ID');
}
const mapping = req.body;
if (!mapping || !mapping.role_name || !mapping.entitlement) {
if (!mapping || !mapping.role.name || !mapping.entitlement) {
throw new InputError(
'Mapping must have role_name and entitlement fields',
'Mapping must have role name and entitlement fields',
);
}
const userToken = getUserTokenFromRequest(req);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ export class AuthzService {
mapping: RoleEntitlementMapping,
userToken?: string,
): Promise<{ data: RoleEntitlementMapping }> {
this.logger.debug(`Creating role mapping for role: ${mapping.role_name}`);
this.logger.debug(`Creating role mapping for role: ${mapping.role?.name}`);

try {
const client = this.createClient(userToken);
Expand All @@ -313,7 +313,7 @@ export class AuthzService {

const mappingResponse = data as RoleMappingResponse;
this.logger.debug(
`Successfully created role mapping for role: ${mapping.role_name}`,
`Successfully created role mapping for role: ${mapping.role.name}`,
);

return { data: mappingResponse.data! };
Expand Down
9 changes: 7 additions & 2 deletions plugins/openchoreo/src/api/OpenChoreoClientApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export interface Entitlement {
}

export interface ResourceHierarchy {
organization?: string;
namespace?: string;
organization_units?: string[];
project?: string;
component?: string;
Expand All @@ -116,13 +116,18 @@ export type PolicyEffect = 'allow' | 'deny';

export interface RoleEntitlementMapping {
id?: number;
role_name: string;
role: RoleEntitlementMappingRoleRef;
entitlement: Entitlement;
hierarchy: ResourceHierarchy;
effect: PolicyEffect;
context?: Record<string, unknown>;
}

export interface RoleEntitlementMappingRoleRef {
name: string;
namespace?: string;
}

/** Filters for listing role mappings */
export interface RoleMappingFilters {
role?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,16 +95,16 @@ export const MappingDialog = ({
);

setWizardState({
selectedRole: editingMapping.role_name,
selectedRole: editingMapping.role.name,
subjectType: matchingUserType?.type || userTypes[0]?.type || '',
entitlementValue: editingMapping.entitlement.value,
scopeType:
editingMapping.hierarchy.organization ||
editingMapping.hierarchy.namespace ||
editingMapping.hierarchy.project ||
editingMapping.hierarchy.component
? 'specific'
: 'global',
organization: editingMapping.hierarchy.organization || '',
organization: editingMapping.hierarchy.namespace || '',
orgUnits: editingMapping.hierarchy.organization_units || [],
project: editingMapping.hierarchy.project || '',
component: editingMapping.hierarchy.component || '',
Expand Down Expand Up @@ -165,7 +165,7 @@ export const MappingDialog = ({
setError(null);

const mapping: RoleEntitlementMapping = {
role_name: wizardState.selectedRole,
role: { name: wizardState.selectedRole },
entitlement: {
claim: entitlementClaim,
value: wizardState.entitlementValue.trim(),
Expand All @@ -174,7 +174,7 @@ export const MappingDialog = ({
wizardState.scopeType === 'global'
? {}
: {
organization: wizardState.organization || undefined,
namespace: wizardState.organization || undefined,
organization_units:
wizardState.orgUnits.filter(u => u.trim()) || undefined,
project: wizardState.project || undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ const useStyles = makeStyles(theme => ({

const formatHierarchy = (hierarchy: ResourceHierarchy): string => {
const parts: string[] = [];
if (hierarchy.organization) parts.push(`org/${hierarchy.organization}`);
if (hierarchy.namespace) parts.push(`org/${hierarchy.namespace}`);
if (hierarchy.organization_units?.length) {
parts.push(`units/${hierarchy.organization_units.join('/')}`);
}
Expand Down Expand Up @@ -228,7 +228,7 @@ export const MappingsTab = () => {
if (searchQuery) {
const query = searchQuery.toLowerCase();
const searchFields = [
mapping.role_name,
mapping.role.name,
mapping.entitlement.claim,
mapping.entitlement.value,
formatHierarchy(mapping.hierarchy),
Expand Down Expand Up @@ -264,7 +264,7 @@ export const MappingsTab = () => {
try {
await deleteMapping(mappingToDelete.id);
notification.showSuccess(
`Mapping for role "${mappingToDelete.role_name}" deleted successfully`,
`Mapping for role "${mappingToDelete.role.name}" deleted successfully`,
);
setDeleteConfirmOpen(false);
setMappingToDelete(null);
Expand Down Expand Up @@ -504,9 +504,9 @@ export const MappingsTab = () => {
</TableHead>
<TableBody>
{filteredMappings.map((mapping, index) => (
<TableRow key={mapping.id ?? `${mapping.role_name}-${index}`}>
<TableRow key={mapping.id ?? `${mapping.role.name}-${index}`}>
<TableCell>
<Typography variant="body2">{mapping.role_name}</Typography>
<Typography variant="body2">{mapping.role.name}</Typography>
</TableCell>
<TableCell className={classes.entitlementCell}>
<Typography variant="body2">
Expand Down Expand Up @@ -585,7 +585,7 @@ export const MappingsTab = () => {
{mappingToDelete && (
<>
Are you sure you want to delete the mapping for role &nbsp;
<strong>{mappingToDelete.role_name}</strong> with entitlement
<strong>{mappingToDelete.role.name}</strong> with entitlement
&nbsp;
<strong>
{mappingToDelete.entitlement.claim}=
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const openchoreoComponentResourceRef = createPermissionResourceRef<
const paramsSchema = z.object({
/** The OpenChoreo action to check (e.g., 'component:deploy') */
action: z.string(),
/** Allowed paths from user's capabilities (e.g., ['org/*', 'org/project/*']) */
/** Allowed paths from user's capabilities (e.g., ['ns/*', 'ns/project/*']) */
allowedPaths: z.array(z.string()),
/** Denied paths from user's capabilities */
deniedPaths: z.array(z.string()),
Expand All @@ -39,27 +39,28 @@ export type MatchesCapabilityParams = z.infer<typeof paramsSchema>;
/**
* Parses capability path from backend format.
*
* Backend format: "org/{orgName}/project/{projectName}/component/{componentName}"
* or wildcards like "org/*", "org/{orgName}/project/*", etc.
* Backend format: "ns/{namespaceName}/project/{projectName}/component/{componentName}"
* or wildcards like "ns/*", "ns/{namespaceName}/project/*", etc.
*
* Returns parsed { org, project, component } values.
* Returns parsed { namespace, project, component } values.
*/
function parseCapabilityPath(path: string): {
org?: string;
namespace?: string;
project?: string;
component?: string;
} {
// Handle global wildcard
if (path === '*') {
return { org: '*', project: '*', component: '*' };
return { namespace: '*', project: '*', component: '*' };
}

const result: { org?: string; project?: string; component?: string } = {};
const result: { namespace?: string; project?: string; component?: string } =
{};

// Parse org/orgName pattern
const orgMatch = path.match(/^org\/([^/]+)/);
if (orgMatch) {
result.org = orgMatch[1];
// Parse namespace/namespaceName pattern
const namespaceMatch = path.match(/^ns\/([^/]+)/);
if (namespaceMatch) {
result.namespace = namespaceMatch[1];
}

// Parse project/projectName pattern
Expand All @@ -82,13 +83,13 @@ function parseCapabilityPath(path: string): {
*
* Paths from backend are in format:
* - "*" - matches everything
* - "org/{orgName}/*" - matches all resources in the org
* - "org/{orgName}/project/{projectName}/*" - matches all resources in the project
* - "org/{orgName}/project/{projectName}/component/{componentName}" - matches specific component
* - "ns/{namespaceName}/*" - matches all resources in the namespace
* - "ns/{namespaceName}/project/{projectName}/*" - matches all resources in the project
* - "ns/{namespaceName}/project/{projectName}/component/{componentName}" - matches specific component
*/
function matchesScope(
path: string,
scope: { org?: string; project?: string; component?: string },
scope: { namespace?: string; project?: string; component?: string },
): boolean {
// Wildcard matches everything
if (path === '*') {
Expand All @@ -97,13 +98,17 @@ function matchesScope(

const parsed = parseCapabilityPath(path);

// Check organization
if (parsed.org && parsed.org !== '*' && parsed.org !== scope.org) {
// Check namespace
if (
parsed.namespace &&
parsed.namespace !== '*' &&
parsed.namespace !== scope.namespace
) {
return false;
}

// If org is wildcard or path only specifies org, it matches
if (parsed.org === '*' || (!parsed.project && !parsed.component)) {
// If namespace is wildcard or path only specifies namespace, it matches
if (parsed.namespace === '*' || (!parsed.project && !parsed.component)) {
return true;
}

Expand Down Expand Up @@ -137,7 +142,7 @@ function matchesScope(
* Permission rule that checks if a user's OpenChoreo capabilities
* allow a specific action on a catalog entity.
*
* The rule extracts the scope (org/project/component) from entity
* The rule extracts the scope (namespace/project/component) from entity
* annotations and matches it against the user's capability patterns.
*/
export const matchesCapability = createPermissionRule({
Expand All @@ -150,17 +155,19 @@ export const matchesCapability = createPermissionRule({
const { allowedPaths, deniedPaths } = params;

// Extract scope from entity annotations
const org = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.ORGANIZATION];
// TODO: need to handle annotation change from org to namespace
const namespace =
entity.metadata.annotations?.[CHOREO_ANNOTATIONS.ORGANIZATION];
const project = entity.metadata.annotations?.[CHOREO_ANNOTATIONS.PROJECT];
const component =
entity.metadata.annotations?.[CHOREO_ANNOTATIONS.COMPONENT];

// If no org annotation, we can't check - deny
if (!org) {
// If no namespace annotation, we can't check - deny
if (!namespace) {
return false;
}

const scope = { org, project, component };
const scope = { namespace, project, component };

// Check if explicitly denied at this scope
for (const deniedPath of deniedPaths) {
Expand Down