Both KubernetesServiceProvidersService and
ContentConfigurationServiceProvidersService return a rawServiceProvider without nodeContext:
rawServiceProviders: [{
name: 'platform-mesh-system',
displayName: '',
creationTimestamp: '',
contentConfiguration: contentConfigurations,
// nodeContext absent
}]
RawServiceProvider in openmfp/portal-server-lib already has
nodeContext?: Record<string, any>, so no upstream change is needed — it just needs to be
populated here.
Request / response contract
The proxy works with these shapes throughout (used by PermissionsProxyService and both adapters):
// src/portal-options/services/permissions/permissions.model.ts
export interface AuthorizationRequest {
userId: string;
accountPath: string; // e.g. "root:orgs:sub:a1"
checks: {
resource: string; // ResourceDefinition.entity
actions: string[] | 'All';
}[];
}
export interface Permission {
resource: string; // ResourceDefinition.entity, e.g. "Account"
actions: string[]; // only the allowed verbs — default-deny (absent = denied)
}
export interface AuthorizationResponse {
userId: string;
accountPath: string;
permissions: Permission[];
}
export interface IPermissionsAdapter {
checkPermissions(req: AuthorizationRequest): Promise<AuthorizationResponse>;
}
PermissionsProxyService
src/portal-options/services/permissions/permissions-proxy.service.ts
-
Selects the adapter once at startup based on environment variables:
| Env var |
Adapter |
Priority |
OPENMFP_PORTAL_CONTEXT_OPEN_FGA_API_URL |
OpenFgaAdapter |
wins if both set |
OPENMFP_PORTAL_CONTEXT_OPEN_RBAC_API_URL |
RbacAdapter |
fallback |
| neither |
— |
logs a warning, returns permissions: [] |
-
Exposes checkPermissions(req: AuthorizationRequest): Promise<AuthorizationResponse>.
-
On adapter error: logs the error, returns { ...req, permissions: [] } — UI treats absent
permissions as fail-open (shows everything).
-
Does not call the adapter when req.checks is empty — returns { ...req, permissions: [] }
immediately.
Helper: extractResourceDefinitions
src/portal-options/services/permissions/extract-resource-definitions.util.ts
Walks ContentConfiguration[].luigiConfigFragment.data.nodes recursively, collects every
node.context.resourceDefinition entry that has checkActions defined, and returns a deduped
AuthorizationRequest['checks'] list. Deduplication key: resource; if the same entity appears
in multiple CCs with different checkActions, merge the action lists (union; 'All' wins).
OpenFgaAdapter
src/portal-options/services/permissions/open-fga.adapter.ts
Translates AuthorizationRequest → OpenFGA BatchCheck → AuthorizationResponse.
Endpoint: POST {OPEN_FGA_API_URL}/stores/{storeId}/batch-check
Request construction — one tuple per (resource, action) pair:
{
"checks": [
{
"tuple_key": {
"user": "user:{userId}",
"relation": "{action}",
"object": "{resource}:{accountPath}"
},
"correlation_id": "{resource}#{action}"
}
]
}
For actions === 'All': the adapter first reads the auth model to discover all relations defined
for the object type, then builds one tuple per relation.
Response mapping — fold the flat result map back into Permission[] using correlation_id:
{
"result": {
"Account#create": { "allowed": true },
"Account#delete": { "allowed": false }
}
}
allowed: true → verb is added to actions[] for that resource; allowed: false → omitted
(default-deny).
RbacAdapter
src/portal-options/services/permissions/rbac.adapter.ts
Translates AuthorizationRequest → Kubernetes RBAC → AuthorizationResponse.
Primary path — SelfSubjectRulesReview (one request per accountPath, returns all allowed
verbs for the calling user):
POST {OPEN_RBAC_API_URL}/apis/authorization.k8s.io/v1/selfsubjectrulesreviews
Request: { "spec": { "namespace": "{accountPath}" } }
Response: {
"status": {
"rules": [
{
"verbs": ["get", "list", "create"],
"apiGroups": ["core.platform-mesh.io"],
"resources": ["accounts"]
}
]
}
}
Mapping rules:
AuthorizationRequest field |
RBAC mapping |
userId |
implicit — carried by the bearer token |
accountPath |
spec.namespace |
checks[].resource |
matched against status.rules[].resources using entityCollection lowercased |
checks[].actions |
intersected with status.rules[].verbs — only matching verbs survive |
checks[].actions === 'All' |
returns the full verbs list from the matched rule |
Fallback — SubjectAccessReview — used only when the bearer token differs from the user
being checked (impersonation flows):
POST {OPEN_RBAC_API_URL}/apis/authorization.k8s.io/v1/subjectaccessreviews
{
"spec": { "user": "{userId}", "verb": "{action}",
"resource": "{entityCollection}", "namespace": "{accountPath}" }
}
One call per (resource, action) pair; results assembled into the same Permission[] shape.
ResourceDefinition.checkActions type addition
resourceDefinition is embedded in node.context inside ContentConfiguration. Add
checkActions?: string[] | 'All' to whatever type represents the CC resourceDefinition in
this repo so extractResourceDefinitions has no TypeScript errors.
Wiring into both service providers
After contentConfigurations is built in each provider:
const checks = extractResourceDefinitions(contentConfigurations);
const { permissions } = checks.length
? await this.permissionsProxyService.checkPermissions({
userId: context.userId,
accountPath: context.accountPath, // resolved from K8sRequestContext
checks,
})
: { permissions: [] };
return {
rawServiceProviders: [{
name: 'platform-mesh-system',
displayName: '',
creationTimestamp: '',
contentConfiguration: contentConfigurations,
nodeContext: { permissions },
}],
};
Apply identically to KubernetesServiceProvidersService and
ContentConfigurationServiceProvidersService.
Acceptance criteria
PermissionsProxyService unit tests cover:
OpenFgaAdapter selected when OPENMFP_PORTAL_CONTEXT_OPEN_FGA_API_URL is set.
RbacAdapter selected when only OPENMFP_PORTAL_CONTEXT_OPEN_RBAC_API_URL is set.
OpenFgaAdapter wins when both env vars are set.
- Neither set →
permissions: [], no adapter call, warning logged.
- Adapter error →
permissions: [], error logged, does not throw.
- Empty
checks → no adapter call, returns immediately.
OpenFgaAdapter unit tests cover:
- Correct tuple construction for explicit verb list.
'All' expansion from auth model relations.
correlation_id → Permission[] folding; allowed: false → verb omitted.
RbacAdapter unit tests cover:
SelfSubjectRulesReview → Permission[] mapping.
'All' returns full verb list from the matched rule.
- Resource not found in rules →
{ resource, actions: [] }.
- Impersonation fallback:
SubjectAccessReview called per verb; results assembled correctly.
- Both
KubernetesServiceProvidersService and ContentConfigurationServiceProvidersService
populate nodeContext.permissions.
- Integration test:
getServiceProviders returns a provider whose nodeContext.permissions
contains at least one { resource, actions } entry matching a CC fixture.
- Export
Permission, AuthorizationRequest, AuthorizationResponse, IPermissionsAdapter,
and PermissionsProxyService from src/portal-options/index.ts.
Both
KubernetesServiceProvidersServiceandContentConfigurationServiceProvidersServicereturn arawServiceProviderwithoutnodeContext:RawServiceProviderinopenmfp/portal-server-libalready hasnodeContext?: Record<string, any>, so no upstream change is needed — it just needs to bepopulated here.
Request / response contract
The proxy works with these shapes throughout (used by
PermissionsProxyServiceand both adapters):PermissionsProxyServicesrc/portal-options/services/permissions/permissions-proxy.service.tsSelects the adapter once at startup based on environment variables:
OPENMFP_PORTAL_CONTEXT_OPEN_FGA_API_URLOpenFgaAdapterOPENMFP_PORTAL_CONTEXT_OPEN_RBAC_API_URLRbacAdapterpermissions: []Exposes
checkPermissions(req: AuthorizationRequest): Promise<AuthorizationResponse>.On adapter error: logs the error, returns
{ ...req, permissions: [] }— UI treats absentpermissions as fail-open (shows everything).
Does not call the adapter when
req.checksis empty — returns{ ...req, permissions: [] }immediately.
Helper:
extractResourceDefinitionssrc/portal-options/services/permissions/extract-resource-definitions.util.tsWalks
ContentConfiguration[].luigiConfigFragment.data.nodesrecursively, collects everynode.context.resourceDefinitionentry that hascheckActionsdefined, and returns a dedupedAuthorizationRequest['checks']list. Deduplication key:resource; if the same entity appearsin multiple CCs with different
checkActions, merge the action lists (union;'All'wins).OpenFgaAdaptersrc/portal-options/services/permissions/open-fga.adapter.tsTranslates
AuthorizationRequest→ OpenFGA BatchCheck →AuthorizationResponse.Endpoint:
POST {OPEN_FGA_API_URL}/stores/{storeId}/batch-checkRequest construction — one tuple per
(resource, action)pair:{ "checks": [ { "tuple_key": { "user": "user:{userId}", "relation": "{action}", "object": "{resource}:{accountPath}" }, "correlation_id": "{resource}#{action}" } ] }For
actions === 'All': the adapter first reads the auth model to discover all relations definedfor the object type, then builds one tuple per relation.
Response mapping — fold the flat
resultmap back intoPermission[]usingcorrelation_id:{ "result": { "Account#create": { "allowed": true }, "Account#delete": { "allowed": false } } }allowed: true→ verb is added toactions[]for that resource;allowed: false→ omitted(default-deny).
RbacAdaptersrc/portal-options/services/permissions/rbac.adapter.tsTranslates
AuthorizationRequest→ Kubernetes RBAC →AuthorizationResponse.Primary path —
SelfSubjectRulesReview(one request peraccountPath, returns all allowedverbs for the calling user):
Mapping rules:
AuthorizationRequestfielduserIdaccountPathspec.namespacechecks[].resourcestatus.rules[].resourcesusingentityCollectionlowercasedchecks[].actionsstatus.rules[].verbs— only matching verbs survivechecks[].actions === 'All'verbslist from the matched ruleFallback —
SubjectAccessReview— used only when the bearer token differs from the userbeing checked (impersonation flows):
One call per
(resource, action)pair; results assembled into the samePermission[]shape.ResourceDefinition.checkActionstype additionresourceDefinitionis embedded innode.contextinsideContentConfiguration. AddcheckActions?: string[] | 'All'to whatever type represents the CCresourceDefinitioninthis repo so
extractResourceDefinitionshas no TypeScript errors.Wiring into both service providers
After
contentConfigurationsis built in each provider:Apply identically to
KubernetesServiceProvidersServiceandContentConfigurationServiceProvidersService.Acceptance criteria
PermissionsProxyServiceunit tests cover:OpenFgaAdapterselected whenOPENMFP_PORTAL_CONTEXT_OPEN_FGA_API_URLis set.RbacAdapterselected when onlyOPENMFP_PORTAL_CONTEXT_OPEN_RBAC_API_URLis set.OpenFgaAdapterwins when both env vars are set.permissions: [], no adapter call, warning logged.permissions: [], error logged, does not throw.checks→ no adapter call, returns immediately.OpenFgaAdapterunit tests cover:'All'expansion from auth model relations.correlation_id→Permission[]folding;allowed: false→ verb omitted.RbacAdapterunit tests cover:SelfSubjectRulesReview→Permission[]mapping.'All'returns full verb list from the matched rule.{ resource, actions: [] }.SubjectAccessReviewcalled per verb; results assembled correctly.KubernetesServiceProvidersServiceandContentConfigurationServiceProvidersServicepopulate
nodeContext.permissions.getServiceProvidersreturns a provider whosenodeContext.permissionscontains at least one
{ resource, actions }entry matching a CC fixture.Permission,AuthorizationRequest,AuthorizationResponse,IPermissionsAdapter,and
PermissionsProxyServicefromsrc/portal-options/index.ts.