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
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 /features | The 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
| Rejected | Why |
|---|---|
An unknown featureKey | 400. Keys are validated against the enum, so a typo cannot become a dead rule |
A rule with no channel, account or user and enabled: false | 400. 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:
- Is the feature missing from
decisionsentirely? Then no rule matches at all, and deny-by-default applies. Add a rule. - Is it present with
granted: false? Theruleshown 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'senabled, delete it, or add a rule at a higher tier. - Are
channelIds/agentGroupIdswhat you expected? If an account is missing, the problem is upstream inusers_channels,users.agent_group_idoragent_group_manager— not here. A KAM who is not linked inagent_group_managerwill 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.