Feature Access
Feature access decides which CRS features a caller can see, from config rather than from a deploy. A rule grants (or revokes) one feature for a channel, an agent group (account), a combination of the two, or a named user.
It lives in dao under com.elivaas.dao.feature (the rule store and the resolver, shared by both apps),
crs under com.elivaas.crs.feature (the gate that enforces it), and admin-api
(PmsFeatureAccessController, the admin CRUD).
This is not a replacement for roles. Roles say what kind of user you are and are carried on the JWT
(ROLE_AGENT, ROLE_PMS_USER, …); feature access says what your channel, account or identity has been
switched on. A surface can be gated by both — @PreAuthorize for the role, the feature gate for the
config.
How It Works
GET /api/v1/me ──► MeService ──► FeatureGate.granted(userId)
│
POST /api/v1/cart/{id}/hold ──► FeatureGate.require(userId, HOLD_WITHOUT_PAY)
│
▼
FeatureAccessService.resolve(userId)
├─ the caller's channels ──── users_channels (user_id → channels_id)
├─ the caller's accounts ──── users.agent_group_id
│ ∪ agent_group_manager.agent_group_id (accounts they KAM)
└─ every rule that could match, in one query ── feature_access
└─ group by feature, keep the most specific tier, grant if any rule in it is enabled
The resolved list is reported on /api/v1/me so the frontend can hide what it should not offer, and
enforced on the endpoint behind each feature. Hiding a button is the cosmetic half; the 403 is the real
one.
The Two Identity Axes
A caller resolves to a set of channels and a set of accounts, not one of each:
| Axis | Source | Typical shape |
|---|---|---|
| Channels | users_channels | An agent sits on one; a KAM often on several (B2C and B2B) |
| Accounts | users.agent_group_id ∪ agent_group_manager | An agent belongs to one account; a KAM manages many |
A non-user rule matches when its channel and its agent group each either name one of the caller's or
are the wildcard. Because both axes are sets, a caller on 2 channels and 3 accounts covers 6 pairs — one
matching pair is enough. This never fans out into 6 queries; it is one IN-list scan.
Rule Shape
Every rule is a row in feature_access. A null scope column means any:
feature_key | channel_id | agent_group_id | user_id | reads as |
|---|---|---|---|---|
LOYALTY | B2C | 40 | — | account 40, when acting on B2C |
LOYALTY | B2C | — | — | everyone on B2C |
LOYALTY | — | 40 | — | account 40, on any channel |
LOYALTY | — | — | user_abc | that one user |
LOYALTY | — | — | — | everyone (global) |
Precedence
Several rules can match one feature, so they are ranked. The most specific tier present decides, and within that tier any enabled rule grants:
tier 3 user_id set
tier 2 channel_id AND agent_group_id set
tier 1 exactly one of channel_id / agent_group_id set
tier 0 both NULL — global
Two consequences worth internalising:
- A user-level rule can revoke.
LOYALTYenabled on (B2C, 40) plus auser_abcrow withenabled: falsemeansuser_abcdoes not get it, even though their channel and account grant it. This is the intended way to take a feature away from one person. - A narrower grant beats a broader denial.
LOYALTYdisabled for all ofB2C, plus enabled for (B2C, 40), means account 40 gets it and the rest of B2C does not. Without this, "off by default, on for these accounts" would be inexpressible.
There is no implicit allow. A feature nobody has configured is invisible to everybody — including a newly added feature key. This is deliberate: the alternative would make every new feature world-visible until someone remembered to lock it down.
Features
| Key | Normally scoped by | Enforced at |
|---|---|---|
PAYMENT_REMINDER | channel + account | /api/v1/my/outstanding-payments — summary, list, all four remind actions |
LOYALTY | channel + account | the loyalty tier/benefits block on GET /api/v1/cart/guest-lookup |
LOYALTY_SEARCH | channel + account | configurable; the feature is not built yet |
TARGET_MANAGEMENT | channel + account | configurable; the feature is not built yet |
PRICE_MODIFICATION | user | POST /api/v1/cart/{cartId}/modification, its upload-url, approve, reject |
HOLD_WITHOUT_PAY | user | POST /api/v1/cart/{cartId}/hold |
OFFLINE_PAYMENT | user | both steps of /api/v1/cart/{cartId}/offline-payment |
CUSTOM_PAYMENT | user | choosing a CUSTOM split instead of the cart's FULL/HALF |
"Normally scoped by" is advisory — it tells the admin UI which form to render. It is not enforced: a channel-scoped feature can still carry a user rule, which is exactly how you revoke it for one person.
The two unbuilt keys are registered so they can be configured ahead of time and so /api/v1/me reports
them; enforcement lands with each screen.
What a Denied Caller Sees
Most gated endpoints answer 403 through the shared error envelope (the gate throws Spring Security's
own AccessDeniedException, which GlobalExceptionHandler already maps). Two surfaces hide instead of
refusing, because refusing would be wrong:
| Surface | Denied behaviour | Why |
|---|---|---|
GET /api/v1/cart/guest-lookup | returns found and the prefill fields, omits loyalty | The lookup is not the gated feature — loyalty recognition is |
holdUsage on every cart response | max: 0 | POST /hold would refuse them, so advertising a spendable quota would make the cart response contradict the endpoint. active still reflects reality — holds placed before the feature was revoked are live and must stay visible |
Deliberately Not Gated
Three actions stay open even when the related feature is off:
| Action | Why it stays open |
|---|---|
DELETE /api/v1/cart/{cartId}/modification (withdraw) | Revoking mid-flight must not strand a cart with a pending request its owner can no longer clear |
DELETE/POST …/hold, …/hold/confirm (release, confirm) | Revoking must not leave live inventory holds nobody is able to release |
POST …/outstanding-payments/{bookingId}/collection-order | Taking a payment is not the reminder feature; gating it would withdraw an unrelated capability |
Data Model
Created by migration 125-add-feature-access.sql:
| Column | Notes |
|---|---|
feature_key | One of the keys above. Validated against the FeatureKey enum on write, so a typo is a 400 rather than a rule that silently never matches |
channel_id | Null = any channel |
agent_group_id | Null = any account |
user_id | Null = not user-scoped |
enabled | false revokes for matching callers |
created_by / updated_by | The acting admin's email, for audit |
channels, agent_group and users are PMS-owned tables created out-of-band, so this table carries no
foreign keys to them — the same treatment agent_group_manager and channel_filter_config already give
them.
Uniqueness is enforced by
UNIQUE INDEX (feature_key, COALESCE(channel_id,'*'), COALESCE(agent_group_id,-1), COALESCE(user_id,'*'))
rather than a plain UNIQUE constraint. SQL treats NULLs as distinct, so a plain constraint would
happily allow unlimited duplicate "all channels, all accounts" rows for one feature — and the upsert
would have no conflict target to land on.
Performance
One resolution is three queries: the caller's channels, their managed accounts, and the matching rules. All are indexed and the rule table is small.
There is no cache, on purpose — admins expect a config change to take effect immediately, and a TTL
would buy a staleness window instead. The one hot path that needed attention is holdUsage, which rides
on every cart response: it short-circuits when agent_hold_cap is absent or zero, because the answer
is 0 either way. Only agents who actually hold a cap pay for the lookup. If cart latency ever shows
this to matter, the next step is per-request memoisation (no staleness), not a TTL.
Next: Configuration.