Skip to content

improvement(pki): add RBAC policy conditions for certificate metadata#5697

Merged
saifsmailbox98 merged 6 commits intomainfrom
saif/pki-146-add-rbac-policy-conditions-for-certificate-metadata
Mar 19, 2026
Merged

improvement(pki): add RBAC policy conditions for certificate metadata#5697
saifsmailbox98 merged 6 commits intomainfrom
saif/pki-146-add-rbac-policy-conditions-for-certificate-metadata

Conversation

@saifsmailbox98
Copy link
Copy Markdown
Contributor

Context

Adds metadata as an RBAC condition for certificates

Screenshots

CleanShot 2026-03-15 at 00 56 55@2x

Steps to verify the change

Type

  • Fix
  • Feature
  • Improvement
  • Breaking
  • Docs
  • Chore

Checklist

  • Title follows the conventional commit format: type(scope): short description (scope is optional, e.g., fix: prevent crash on sync or fix(api): handle null response).
  • Tested locally
  • Updated docs (if needed)
  • Updated CLAUDE.md files (if needed)
  • Read the contributing guide

@linear
Copy link
Copy Markdown

linear bot commented Mar 14, 2026

@maidul98
Copy link
Copy Markdown
Collaborator

maidul98 commented Mar 14, 2026

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 14, 2026

Greptile Summary

This PR extends certificate RBAC by adding metadata as a permission condition for certificates, allowing fine-grained access control based on certificate metadata key/value pairs. It also consistently updates altNames handling from a comma-separated string to a proper array across all permission subject calls.

Key changes and findings:

  • Information disclosure in getCertificateRequest: The certificate request fetch was moved before the permission check (needed to obtain metadata for RBAC). This allows unauthorized users to distinguish non-existent requests (404) from ones they lack access to (403) by probing UUIDs. Consider adding a general permission guard before the DB fetch.
  • Permissive metadata condition schema: The outer .partial() on the metadata condition in CertificateConditionSchema makes the $ELEMENTMATCH operator optional, meaning metadata: {} is a valid (and potentially permissive) condition. Removing the outer .partial() would enforce the element-match operator when the metadata key is present.
  • Dual API calls in CertificateCertModal: useGetCertBody is now always fetched as a fallback even when the user already has ReadPrivateKey permission and bundleData resolves successfully — resulting in an extra redundant request for users with full access.
  • Single key/value condition limitation: The ConditionsFields deduplication logic limits users to one metadataKey and one metadataValue condition per rule. Multi-key matching (e.g., env=prod AND team=infra) is not expressible through the UI and is worth documenting.
  • The double permission check in certificate-v3-service.ts (current metadata → new metadata on edit) is a correct approach to prevent privilege escalation.

Confidence Score: 3/5

  • Mergeable with caution — the information disclosure in getCertificateRequest and the permissive metadata condition schema should be addressed before broad rollout.
  • The core RBAC logic is sound and the frontend/backend types are kept in sync, but the reordering of the DB fetch before the permission check in getCertificateRequest introduces a resource-existence information disclosure. The outer .partial() on the metadata condition schema is a subtle security concern that could allow an empty-condition bypass. These are not critical blockers but warrant fixes before the feature is widely used.
  • backend/src/services/certificate-request/certificate-request-service.ts (auth ordering) and backend/src/ee/services/permission/project-permission.ts (metadata condition schema) need the most attention.

Important Files Changed

Filename Overview
backend/src/services/certificate-request/certificate-request-service.ts Moved cert-request DB fetch before permission check to support metadata-based RBAC, introducing an information disclosure: unauthorized users can now distinguish non-existent (404) from forbidden (403) certificate requests.
backend/src/ee/services/permission/project-permission.ts Adds metadata field to CertificateConditionSchema via $ELEMENTMATCH; outer .partial() makes the operator optional, risking an empty-object bypass of metadata conditions in CASL.
frontend/src/pages/cert-manager/CertificatesPage/components/CertificateCertModal.tsx Now always fetches both cert body and bundle (when permission permits), causing a redundant API call for users who already have full ReadPrivateKey access.
backend/src/services/certificate-v3/certificate-v3-service.ts Adds metadata to permission subjects and includes a double-check (current metadata + incoming metadata) on edit, which is the correct approach to prevent privilege escalation via metadata assignment.
frontend/src/pages/project/RoleDetailsBySlugPage/components/CertificatePermissionConditions.tsx Adds metadataKey and metadataValue as UI condition options; these map correctly to the backend $ELEMENTMATCH structure via ProjectRoleModifySection.utils, but the deduplication logic limits users to one key and one value condition per rule.

Comments Outside Diff (4)

  1. backend/src/services/certificate-request/certificate-request-service.ts, line 187-200 (link)

    Information disclosure: resource existence exposed before authorization

    The refactor moved the certificateRequestDAL.findById call and its NotFoundError check before the permission check. This means an unauthorized user can probe arbitrary certificateRequestId UUIDs and distinguish between non-existent requests (404) and ones they lack access to (403).

    In the original code, the general ForbiddenError was thrown first, preventing any information leakage. A potential attacker who already knows valid project IDs (or can enumerate them) can now confirm whether specific certificate request UUIDs exist within a project without any permission.

    While certificate request IDs are UUIDs (low-probability brute force), this pattern conflicts with the "resource existence is also confidential" principle applied elsewhere in the codebase.

    Consider fetching project permission first with a basic subject-level check and then re-verifying with the full metadata-enriched subject after the fetch, or simply preserving the original ordering if no other refactors are needed:

    // Option: keep permission check first (general), then fetch + re-check with metadata
    const { permission } = await permissionService.getProjectPermission({ ... });
    ForbiddenError.from(permission).throwUnlessCan(
      ProjectPermissionCertificateActions.Read,
      ProjectPermissionSub.Certificates  // general guard
    );
    
    const certificateRequest = await certificateRequestDAL.findById(certificateRequestId);
    if (!certificateRequest) throw new NotFoundError(...);
    if (certificateRequest.projectId !== projectId) throw new NotFoundError(...);
    
    const metadataRows = await resourceMetadataDAL.find({ certificateRequestId: certificateRequest.id });
    // fine-grained re-check with metadata
    ForbiddenError.from(permission).throwUnlessCan(
      ProjectPermissionCertificateActions.Read,
      subject(ProjectPermissionSub.Certificates, { ..., metadata: requestMetadata })
    );
    
  2. frontend/src/pages/cert-manager/CertificatesPage/components/CertificateCertModal.tsx, line 33-46 (link)

    Redundant API call when user has full ReadPrivateKey permission

    With this change, useGetCertBody is always called (enabled whenever serialNumber is non-empty). When canReadPrivateKey is true and bundleData loads successfully, bodyData is also fetched unnecessarily — doubling the number of API requests for users with full permissions.

    The comments acknowledge that the generic canReadPrivateKey check can be "overly optimistic" with metadata-based conditions, but this results in two network requests for the common case where the user has unrestricted access.

    Consider disabling useGetCertBody when bundleData has already resolved successfully, or only enabling it when the bundle is unavailable:

    const { data: bundleData } = useGetCertBundle(canReadPrivateKey ? serialNumber : "");
    // Only fetch body if bundle was skipped or hasn't resolved
    const { data: bodyData } = useGetCertBody(!canReadPrivateKey || bundleData === undefined ? serialNumber : "");
    const data = bundleData ?? bodyData;
  3. frontend/src/pages/project/RoleDetailsBySlugPage/components/CertificatePermissionConditions.tsx, line 33-39 (link)

    Single metadataKey + single metadataValue condition limit

    The ConditionsFields component deduplicates condition types via usedConditionTypes, which prevents the same lhs value from being added more than once. As a result, a user can only define one metadataKey condition and one metadataValue condition per permission rule. This means it is impossible to express "certificate must have metadata key A AND metadata key B" through the UI.

    If supporting multiple key/value pairs in a single RBAC condition is a desired use-case (e.g., require both env=prod and team=infra), this architecture would need to change. It's worth documenting this limitation somewhere so users understand what they can express in the conditions UI.

  4. backend/src/ee/services/permission/project-permission.ts, line 1347-1366 (link)

    .partial() on outer metadata condition allows empty-object bypass

    The outer .partial() call makes $ELEMENTMATCH itself optional on the metadata condition object. This means metadata: {} is a valid policy condition that passes schema validation. In CASL, an empty condition object typically evaluates as "matches anything," so a policy stored as { metadata: {} } would grant access to certificates regardless of their actual metadata contents — potentially bypassing the intended restriction.

    Consider removing the outer .partial() to require that the element-match operator is always present when metadata is specified. The inner .partial() on the element-match body (making key and value individually optional) can remain.

Last reviewed commit: 5d4cfca

@saifsmailbox98
Copy link
Copy Markdown
Contributor Author

@greptile re-review this PR

@saifsmailbox98
Copy link
Copy Markdown
Contributor Author

@greptile

  1. We had to flip the order because to check the permissions, we need the metadata from the certificate object. It is not easy to enumerate over UUIDs.
  2. I think the solution we have handles it pretty nicely and does not need any change
  3. This is how condition fields work.
  4. We do similar things for other entities as well.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 14, 2026

Fair points on 2-4. If this follows existing patterns across the codebase, that makes sense.

On point 1 though: While UUID enumeration is indeed hard, the information disclosure concern isn't about brute-forcing UUIDs — it's about what happens when someone does have a valid UUID (from logs, URLs, error messages, etc.). The original ordering prevented distinguishing "doesn't exist" from "you can't access it," which is a deliberate security pattern.

If you need metadata for RBAC and this reordering is unavoidable, consider the two-stage check I suggested: a lightweight permission guard before the fetch (general Certificates subject), then the full metadata-enriched check after. That way you get the metadata you need without leaking existence info to unauthorized users.

But if this pattern is already used for other entities and you've assessed the risk, I'll defer to that architectural decision.

@carlosmonastyrski carlosmonastyrski self-requested a review March 16, 2026 14:55
@saifsmailbox98 saifsmailbox98 merged commit 8548f92 into main Mar 19, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants