Skip to main content

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:

AxisSourceTypical shape
Channelsusers_channelsAn agent sits on one; a KAM often on several (B2C and B2B)
Accountsusers.agent_group_idagent_group_managerAn 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_keychannel_idagent_group_iduser_idreads as
LOYALTYB2C40account 40, when acting on B2C
LOYALTYB2Ceveryone on B2C
LOYALTY40account 40, on any channel
LOYALTYuser_abcthat one user
LOYALTYeveryone (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. LOYALTY enabled on (B2C, 40) plus a user_abc row with enabled: false means user_abc does 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. LOYALTY disabled for all of B2C, 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.
A feature with no matching rule is denied

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

KeyNormally scoped byEnforced at
PAYMENT_REMINDERchannel + account/api/v1/my/outstanding-payments — summary, list, all four remind actions
LOYALTYchannel + accountthe loyalty tier/benefits block on GET /api/v1/cart/guest-lookup
LOYALTY_SEARCHchannel + accountconfigurable; the feature is not built yet
TARGET_MANAGEMENTchannel + accountconfigurable; the feature is not built yet
PRICE_MODIFICATIONuserPOST /api/v1/cart/{cartId}/modification, its upload-url, approve, reject
HOLD_WITHOUT_PAYuserPOST /api/v1/cart/{cartId}/hold
OFFLINE_PAYMENTuserboth steps of /api/v1/cart/{cartId}/offline-payment
CUSTOM_PAYMENTuserchoosing 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:

SurfaceDenied behaviourWhy
GET /api/v1/cart/guest-lookupreturns found and the prefill fields, omits loyaltyThe lookup is not the gated feature — loyalty recognition is
holdUsage on every cart responsemax: 0POST /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:

ActionWhy 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-orderTaking a payment is not the reminder feature; gating it would withdraw an unrelated capability

Data Model

Created by migration 125-add-feature-access.sql:

ColumnNotes
feature_keyOne 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_idNull = any channel
agent_group_idNull = any account
user_idNull = not user-scoped
enabledfalse revokes for matching callers
created_by / updated_byThe 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.

Why the unique index is expression-based

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.