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
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,19 @@ export class PermitAuthorizationProvider implements AuthorizationProvider {
}
// Idempotent: the helper returns false (not throws) if the assignment is
// already gone, which we treat as success.
await unassignRoleInPermit({ user: subject, role, tenant })
//
// resource_instance MUST be forwarded when req.resource is present: Permit
// keys a role assignment by the full (user, role, tenant, resource_instance)
// tuple, so an instance-scoped grant (ReBAC — e.g. one list) is a distinct
// record from a tenant-wide one. Omitting it here would revoke nothing for
// a caller that only ever held the scoped assignment, while this call
// still resolves successfully — a silent no-op revoke.
await unassignRoleInPermit({
user: subject,
role,
tenant,
resource_instance: resourceInstance(req.resource),
})
}

async listGrants(query: GrantQuery): Promise<Page<Grant>> {
Expand Down
16 changes: 14 additions & 2 deletions backend/security/src/utils/permit/role-assignment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,22 @@ export async function assignRoleInPermit(
}

/**
* Unassigns a role from a user in an organization (tenant)
* Unassigns a role from a user in an organization (tenant).
*
* `resource_instance` IS accepted (unlike an earlier revision of this
* signature, which omitted it): Permit identifies a role assignment by the
* full (user, role, tenant, resource_instance) tuple, so an instance-scoped
* assignment (ReBAC, e.g. `SelectionList:sl_123`) is a DIFFERENT record from
* the tenant-wide one and is not removed by an unassign call that leaves
* `resource_instance` off. Dropping it silently no-ops the revocation of a
* scoped grant while still returning success — a caller believes access was
* revoked when Permit's state is unchanged. See
* `PermitAuthorizationProvider.revoke()`, the only caller that has a
* `resource` to pass; the organization-role helpers below intentionally
* never scope by instance and are unaffected by this being optional.
*/
export async function unassignRoleInPermit(
assignment: Omit<RoleAssignment, 'resource_instance'>
assignment: RoleAssignment
): Promise<boolean> {
try {
await permit.api.roleAssignments.unassign(assignment)
Expand Down
103 changes: 103 additions & 0 deletions backend/security/tests/role-revoke.resource-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* Regression test for a resource-instance-scoping gap in the Permit-backed
* revoke path.
*
* `GrantRevokeRequest.resource` (the contract's ReBAC scope, mirrored from
* `@fuzefront/auth`'s `authzTypes.ts`) was accepted by
* `PermitAuthorizationProvider.revoke()` but silently discarded before this
* fix — `unassignRoleInPermit()`'s parameter type explicitly `Omit`ted
* `resource_instance`. Permit keys a role assignment by the FULL
* (user, role, tenant, resource_instance) tuple, so an instance-scoped grant
* (e.g. `SelectionList:sl_123`) is a different record from a tenant-wide one:
* dropping `resource_instance` meant a caller revoking one list's grant would
* get a 204 while Permit's state was unchanged — a silent no-op revoke, the
* opposite of what the caller asked for and believes happened.
*
* This mattered immediately for `selection-list-service`'s migration off an
* embedded Permit SDK onto this Security API (step 2 of 3): its
* `DELETE /:listId/access/:userId` depends on `AuthzClient.revoke()` actually
* reaching Permit with the list's `resource_instance`.
*/
process.env.NODE_ENV = 'test'
process.env.PERMIT_API_KEY = 'ci-no-real-permit-calls'

const unassignMock = jest.fn().mockResolvedValue({})

jest.mock('../src/config/permit', () => ({
__esModule: true,
default: {
api: {
roleAssignments: {
assign: jest.fn().mockResolvedValue({}),
unassign: unassignMock,
list: jest.fn().mockResolvedValue([]),
},
},
},
permitConfig: { token: 'ci-no-real-permit-calls', pdp: 'http://localhost:7766' },
}))

import { unassignRoleInPermit } from '../src/utils/permit/role-assignment'
import { PermitAuthorizationProvider } from '../src/providers/permit/PermitAuthorizationProvider'

describe('unassignRoleInPermit — forwards resource_instance', () => {
beforeEach(() => unassignMock.mockClear())

it('passes resource_instance through to permit.api.roleAssignments.unassign when supplied', async () => {
await unassignRoleInPermit({
user: 'usr_1',
role: 'list-owner',
tenant: 'org_acme',
resource_instance: 'SelectionList:sl_123',
})

expect(unassignMock).toHaveBeenCalledWith({
user: 'usr_1',
role: 'list-owner',
tenant: 'org_acme',
resource_instance: 'SelectionList:sl_123',
})
});

it('omits resource_instance for a tenant-wide unassign (organization-role helpers)', async () => {
await unassignRoleInPermit({ user: 'usr_1', role: 'admin', tenant: 'org_acme' })

expect(unassignMock).toHaveBeenCalledWith({
user: 'usr_1',
role: 'admin',
tenant: 'org_acme',
})
});
})

describe('PermitAuthorizationProvider.revoke — forwards req.resource as resource_instance', () => {
beforeEach(() => unassignMock.mockClear())
const provider = new PermitAuthorizationProvider()

it('scopes the revoke to the resource instance when req.resource is present', async () => {
await provider.revoke({
subject: 'usr_1',
tenant: 'org_acme',
role: 'list-owner',
resource: { type: 'SelectionList', key: 'sl_123' },
})

expect(unassignMock).toHaveBeenCalledWith({
user: 'usr_1',
role: 'list-owner',
tenant: 'org_acme',
resource_instance: 'SelectionList:sl_123',
})
});

it('leaves resource_instance undefined (tenant-wide) when req.resource is absent', async () => {
await provider.revoke({ subject: 'usr_1', tenant: 'org_acme', role: 'admin' })

expect(unassignMock).toHaveBeenCalledWith({
user: 'usr_1',
role: 'admin',
tenant: 'org_acme',
resource_instance: undefined,
})
});
})
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@
# THIS FILE IS A TEMPLATE. IT CONTAINS NO SECRET AND IS NOT APPLIED.
# ============================================================================
#
# Seal the three service credentials with kubeseal before enabling the service.
# Seal the two service credentials with kubeseal before enabling the service.
# Each key must be sealed against the cluster's sealing certificate:
#
# kubectl create secret generic selection-list-secrets \
# -n fuzefront \
# --from-literal=DATABASE_URL='postgresql://<user>:<password>@<host>/<db>' \
# --from-literal=JWT_SECRET='<long-random-value>' \
# --from-literal=PERMIT_API_KEY='<permit.io-api-key>' \
# --dry-run=client -o yaml \
# | kubeseal \
# --controller-name sealed-secrets \
Expand All @@ -33,14 +32,20 @@
# JWT_SECRET) so the service can verify tokens the backend mints.
# Alternative: merge this key into fuzefront-secrets with
# `--merge-into` and mount from that Secret instead.
# PERMIT_API_KEY — Permit.io API key scoped to the FuzeFront environment.
# Same key as in fuzefront-secrets PERMIT_API_KEY; same
# alternative applies.
#
# NOTE: selection-list-service's authorization is routed through FuzeFront's
# own Security API (SECURITY_SERVICE_URL, a plain in-cluster Service DNS
# value — see templates/selection-list-service-deployment.yaml) instead of an
# embedded Permit.io SDK (step 2 of a 3-step migration; config-service was
# step 1 — see its own sealed-secret template for that precedent). There is
# no PERMIT_API_KEY/permitPdpUrl here any more: selection-list-service
# carries no vendor SDK or vendor API key at all — the Security API is the
# only thing it talks to, and Permit (if used) is entirely behind that API.
#
# ---------------------------------------------------------------------------
# GO-LIVE sequence (deploy window)
# ---------------------------------------------------------------------------
# 1. Seal all three keys (run command above, commit the output).
# 1. Seal both keys (run command above, commit the output).
# 2. Apply the SealedSecret:
# kubectl apply -f deploy/contabo/sealed/selection-list-service-secrets.yaml
# 3. Flip `selectionListService.enabled: true` in values-prod.yaml via GitOps.
Expand All @@ -50,8 +55,8 @@
# ---------------------------------------------------------------------------
# Rotation
# ---------------------------------------------------------------------------
# Re-seal all three keys in one change; a partial re-seal with `--merge-into`
# can be used for individual key rotation without touching the others.
# Re-seal both keys in one change; a partial re-seal with `--merge-into`
# can be used for individual key rotation without touching the other.
#
---
# Replace the entire block below with the kubeseal output.
Expand All @@ -65,7 +70,6 @@ spec:
# Replace with kubeseal output. Do NOT commit plaintext.
DATABASE_URL: <REPLACE_WITH_KUBESEAL_OUTPUT>
JWT_SECRET: <REPLACE_WITH_KUBESEAL_OUTPUT>
PERMIT_API_KEY: <REPLACE_WITH_KUBESEAL_OUTPUT>
template:
metadata:
name: selection-list-secrets
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,17 @@ spec:
secretKeyRef:
name: selection-list-secrets
key: JWT_SECRET
- name: PERMIT_API_KEY
valueFrom:
secretKeyRef:
name: selection-list-secrets
key: PERMIT_API_KEY
- name: SECURITY_SERVICE_URL
# fuzefront-security is the in-cluster Service name for security-service
# (port 3002). See templates/security.yaml and
# services/provisioning-service.yaml / config-service-deployment.yaml,
# which use the same convention. selection-list-service's
# authorization is now routed through this Service
# (src/middleware/authz.ts) instead of an embedded Permit.io SDK --
# PERMIT_API_KEY/permitPdpUrl are gone; this is not a secret, it is
# just the internal DNS name of another Service in the same
# namespace.
value: "http://fuzefront-security:{{ .Values.securityService.port }}"
readinessProbe:
httpGet:
path: /health
Expand Down
10 changes: 8 additions & 2 deletions deploy/helm/fuzefront/values-prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -618,8 +618,14 @@ notificationService:

# Selection-list-service (S14 / FFRNT-200). Ships dark (enabled: false) until
# the SealedSecret `selection-list-secrets` is provisioned in a deploy window.
# Feature flag: fuzefront.selection-lists.service (S15). GO-LIVE steps:
# 1. Seal DATABASE_URL, JWT_SECRET, PERMIT_API_KEY into selection-list-secrets
# Feature flag: fuzefront.selection-lists.service (S15). Authorization is
# routed through FuzeFront's own Security API (SECURITY_SERVICE_URL — plain
# in-cluster Service DNS, set in templates/selection-list-service-deployment.yaml,
# no secret needed) rather than an embedded Permit.io SDK, so there is no
# PERMIT_API_KEY/permitPdpUrl to seal here (step 2 of the 3-step migration —
# config-service was step 1; see the configService comment below for that
# precedent). GO-LIVE steps:
# 1. Seal DATABASE_URL, JWT_SECRET into selection-list-secrets
# (see deploy/contabo/sealed/selection-list-service-secrets.yaml.template).
# 2. Flip `enabled` to true here via GitOps in a deploy window.
# The tag: line MUST stay immediately after repository: — release.yml's GitOps
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading