Skip to main content

Configuration

All configuration is through the Admin/PMS API. There is no environment variable, no feature-flag file, and no deploy involved — a rule change takes effect on the caller's next request.

Every endpoint below is dual-mapped: /api/v1/admin/feature-access/** and /api/v1/pms/feature-access/** are the same handler. Full request/response reference: Feature Access.

Current State: Everything Is Granted

Nothing is restricted until someone narrows the seeded rules

Because a feature with no rule is denied, shipping this with an empty table would have withdrawn working functionality from everyone. The migration therefore seeds a global enabled rule for each of the six features that already existed:

PAYMENT_REMINDER, LOYALTY, PRICE_MODIFICATION, HOLD_WITHOUT_PAY, OFFLINE_PAYMENT, CUSTOM_PAYMENT.

The deploy itself was a no-op by design. The restrictions only apply once an admin narrows each feature: delete the global row, then add the channel / agent-group / user rows that should have it.

LOYALTY_SEARCH and TARGET_MANAGEMENT were seeded with nothing and are denied to everyone.

The Endpoints

GET /featuresThe registry — every key, its label, and the axis it is normally scoped on. Drives the admin form
GET /Rules, filterable by featureKey / channelId / agentGroupId / userId. Each filter narrows independently
POST /Create or update a rule
DELETE /{id}Delete a rule
GET /resolve?userId=What a user actually resolves to, and which rule decided each feature

Creating a Rule

POST upserts on the four-column key (featureKey, channelId, agentGroupId, userId). Re-sending the same combination flips its enabled rather than adding a second row. Omit a scope field to mean any.

# Payment reminders for account 40 on B2C
curl -X POST https://<pms-host>/api/v1/pms/feature-access \
-H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \
-d '{"featureKey":"PAYMENT_REMINDER","channelId":"B2C","agentGroupId":40,"enabled":true}'
# Price modification for one named user
curl -X POST https://<pms-host>/api/v1/pms/feature-access \
-H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \
-d '{"featureKey":"PRICE_MODIFICATION","userId":"user_YPuCpUrxdxsCRx","enabled":true}'
# Loyalty is on for all of B2C, but not for this one agent
curl -X POST https://<pms-host>/api/v1/pms/feature-access \
-H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \
-d '{"featureKey":"LOYALTY","userId":"user_YPuCpUrxdxsCRx","enabled":false}'

The acting admin's email is taken from the JWT and stored as created_by / updated_by.

Validation

RejectedWhy
An unknown featureKey400. Keys are validated against the enum, so a typo cannot become a dead rule
A rule with no channel, account or user and enabled: false400. A feature with no rules is already denied, so a global "off" adds nothing — and it would shadow every narrower grant you add afterwards. Delete the rule instead

Blank strings in channelId / userId are treated as null (wildcard), and enabled defaults to true when omitted.

Narrowing a Seeded Feature

The two steps must happen in this order or there is a window where nobody has the feature.

# 1. Add the rules that SHOULD have it
POST /api/v1/pms/feature-access {"featureKey":"PAYMENT_REMINDER","channelId":"B2C","agentGroupId":40}
POST /api/v1/pms/feature-access {"featureKey":"PAYMENT_REMINDER","channelId":"B2B","agentGroupId":41}

# 2. Find the seeded global row (channelId, agentGroupId and userId all null) and delete it
GET /api/v1/pms/feature-access?featureKey=PAYMENT_REMINDER
DELETE /api/v1/pms/feature-access/{id}

Adding first is harmless — while the global row is still there the new rows are redundant, since tier 2 and tier 0 both grant. Deleting first would deny everyone until step 1 landed.

Debugging: Why Can't This Agent See It?

GET /resolve?userId= is the endpoint to reach for. It returns the resolved list and the working: the caller's channels and accounts, plus the rule that decided each feature.

curl 'https://<pms-host>/api/v1/pms/feature-access/resolve?userId=user_YPuCpUrxdxsCRx' \
-H 'Authorization: Bearer <token>'
{
"userId": "user_YPuCpUrxdxsCRx",
"channelIds": ["B2B", "B2C"],
"agentGroupIds": [40, 41],
"features": ["OFFLINE_PAYMENT", "PAYMENT_REMINDER"],
"decisions": {
"PAYMENT_REMINDER": {
"granted": true,
"rule": { "featureKey": "PAYMENT_REMINDER", "channelId": "B2C", "agentGroupId": 40, "enabled": true }
},
"LOYALTY": {
"granted": false,
"rule": { "featureKey": "LOYALTY", "userId": "user_YPuCpUrxdxsCRx", "enabled": false }
}
}
}

Read it in this order:

  1. Is the feature missing from decisions entirely? Then no rule matches at all, and deny-by-default applies. Add a rule.
  2. Is it present with granted: false? The rule shown is the one that won. It is winning because it is the most specific match — so adding a broader grant will not help. Either flip that rule's enabled, delete it, or add a rule at a higher tier.
  3. Are channelIds / agentGroupIds what you expected? If an account is missing, the problem is upstream in users_channels, users.agent_group_id or agent_group_manager — not here. A KAM who is not linked in agent_group_manager will not pick up that account's grants.

Consuming It From the Frontend

GET /api/v1/me (CRS) carries the resolved list:

{
"userId": "user_YPuCpUrxdxsCRx",
"name": "Riaz Quadri",
"roles": ["ROLE_AGENT"],
"channels": [{ "id": "B2C", "name": "B2C" }],
"features": ["PAYMENT_REMINDER", "PRICE_MODIFICATION", "LOYALTY"]
}

Hide any surface whose key is absent. The list is always present — an empty array when nothing is granted, never null.

Do not treat it as a security boundary: it exists so the UI does not offer actions that would fail. The endpoints enforce independently and answer 403 regardless of what the client renders.