Dentolize · Plugin Platform Walkthrough
On this pageBusiness viewTechnical view

Scopes & consent

Business view

Every plugin has to declare, in advance, exactly what it wants to read or change — there's no "full access" option. Dentolize groups those requests into four sensitivity tiers, shown to the clinic owner in that order, from least to most sensitive:

  1. Basic — non-personal clinic data: your clinic's name and branches, the staff directory (no phone numbers), the procedure price list.
  2. Operational — business records that don't identify a specific patient by name: appointment times and statuses, invoice totals, insurance policy structures.
  3. Financial — money-shaped data: quotation totals, insurance claim amounts, the ability to record a payment or generate a payment link. Shown with a money icon and an extra warning.
  4. Patient personal data — anything that identifies or describes a specific patient: names, phone numbers, medical notes, prescriptions, x-rays. Shown with a red warning banner: "This plugin will be able to access patients' personal and medical information. Only continue if you trust the developer and have a lawful basis to share this data."

A plugin that only wants to read appointment slots gets a short, unremarkable consent screen. A plugin that wants patient medical records gets a screen the owner cannot miss.

Two protections sit underneath this that are easy to overlook:

  • A plugin can never ask for something the platform doesn't have a description for. If a listing somehow requests a scope or event that isn't in Dentolize's own dictionary of known permissions, the install button is disabled entirely, with an error explaining the platform doesn't recognize what's being requested. A plugin cannot smuggle in an undocumented permission.
  • "Read" data about a patient is masked by default. A plugin can be granted patients:read and see patient records with real IDs and branch/gender info, but phone numbers show as +9665•••••42 and there's no name at all — unless it separately holds patients:read.pii, a distinct, more sensitive scope.

Technical view

The scope catalog

32 scopes are defined in two places that must stay in sync: the server catalog (packages/server/src/plugins/auth/scopes.js) and its SDK mirror (packages/plugin-sdk/src/scopes.ts:12-41, ALL_SCOPES). Each scope carries a tier (basic | operational | financial | patient_pii) and an optional implies list — e.g. patients:read.pii implies patients:read, so granting the more specific scope doesn't also require separately granting the base one.

A representative sample (full table in docs/plugin-platform/02-authentication.md):

ScopeTierGrants
clinic:read, practitioners:read, procedures:readbasicRead-only directory data
patients:readoperationalPatients with masked PII
appointments:read / :writeoperationalRead / create-update-cancel appointments
quotations:read, claims:read, onlinepayments:readfinancialPricing/estimate/claim data
payments:writefinancialRecord external payments — the full platform money path
patients:read.piipatient_piiFull identity fields (implies patients:read)
files:read / :writepatient_piiPatient files and x-rays

A missing-scope API call returns 403 { error: { code: "missing_scope", details: { missingScopes: [...] } } } — never a silent empty result.

packages/clinic-web-canary/src/features/PluginHub/scopeDescriptions.ts is the platform-owned copy that renders every scope and event as a human sentence on the consent screen — never text authored by the plugin's listing. Its header comment states it mirrors the server's scope/event catalogs exactly, and a script (__manual__/checkScopeCoverage.mjs) checks that coverage in CI. findUnknownConsentKeys() is what disables Install if a plugin's requestedScopes/requestedEvents contain anything missing from this dictionary — a hard block, not a warning, implemented in PluginDetail.tsx and InstallPluginModal.tsx.

The tier ordering shown to users (SCOPE_TIER_ORDER, scopeDescriptions.ts) is fixed: basic → operational → financial → patient_pii, matching ConsentLists.tsx's ScopeList component, which renders the tier headers, the per-scope icon + sentence + raw scope code, and the tier-level warning alerts for financial and patient_pii.

PII masking in responses

With patients:read alone, a patient object returned by the API contains stable identifiers (id, file number, gender, branch) and a masked phone hint. With patients:read.pii added, the same route additionally returns name, full phone, email, birth date, address, national ID, and insurance identifiers. This is enforced by whitelist-only serializers (compliance/fieldTiers.js, per docs/plugin-platform/compliance.md) — never-exposed fields (signature images, geolocation, internal notes) are excluded from the Prisma select itself, not filtered after the fact.

Every response that includes PII is written to the clinic's audit log (ApiRequestLog) with the token id, route, and record ids returned — visible to the clinic via the pluginApiRequestLogs query (permission MANAGE_PLUGINS).

The all-or-nothing reality, as shipped

The schema supports Plugin.optionalScopes[] — scopes a clinic could grant individually beyond the required set — and installPlugin's validation logic (packages/server/src/plugins/registry/validation.js:89) already accepts a partial grant against that list. As of this PR, though, neither the admin listing editor exposes a field to set optionalScopes, nor does the install modal offer a per-scope toggle — it submits grantedScopes: plugin.requestedScopes verbatim (InstallPluginModal.tsx). So the practical behavior today is what the business view describes: fully all-or-nothing consent. See How plugins work for more on this gap.