Skip to content
Merged
78 changes: 47 additions & 31 deletions PERMISSION_SYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Roles are **not hierarchical**; for instance, an Administrator does not automati
- **Permissions**:
- View public cave/entrance/document/organisation/massif/person data
- Search through cave/entrance/document/organisation/massif/person/device data
- View legislation guidelines (list, by geographic entity, and snapshots)
- View legislation guidelines (list, single guideline by id, by geographic entity, and snapshots)
- View the organizations responsible for a country/region/massif
- View complete entity history
- View statistics
Expand All @@ -29,9 +29,9 @@ Roles are **not hierarchical**; for instance, an Administrator does not automati
- Create legislation guidelines
- Edit caves, entrances, documents, massifs, organisations, descriptions, locations, riggings, histories
- Edit own comments
- Edit own legislation guidelines
- Rollback own legislation guidelines to a previous version (guidelines are the only entity exposing a rollback
route)
- Edit any legislation guideline, including other users' (no ownership or role check)
- Rollback any legislation guideline to a previous version, including other users' (guidelines are the only entity
exposing a rollback route)
- Associate/dissociate responsible organizations for any country, region, or massif
- Add/remove own explored entrances
- Set main name for entities
Expand Down Expand Up @@ -76,7 +76,6 @@ Roles are **not hierarchical**; for instance, an Administrator does not automati
- Permanently delete author records (`type: AUTHOR`; deleting a `CAVER` account requires Administrator instead β€”
see "Caver Deletion")
- Update any user's comments
- Update/rollback any user's legislation guidelines
- Unlink documents from caves, entrances, and massifs
- Update documents that have modifications pending moderator approval
- Validate documents
Expand Down Expand Up @@ -106,9 +105,9 @@ Roles are **not hierarchical**; for instance, an Administrator does not automati
- **System Operations**:
- Import data from CSV files (documents and entrances)
- System configuration and maintenance
- **Content Moderation** (guidelines only; other content moderation is Moderator-only):
- Update/rollback any user's legislation guidelines
- Delete/restore legislation guidelines
- **Content Moderation** (guideline deletion only; other content moderation is Moderator-only):
- Delete/restore legislation guidelines β€” the only guideline operation an Administrator gates, since updating and
rolling back them is open to any authenticated user
- **Sensitive Data Management**:
- View coordinates of sensitive entrances
- Remove sensitive flag from entrances
Expand All @@ -134,7 +133,7 @@ Roles are **not hierarchical**; for instance, an Administrator does not automati
| Search content (including devices) | βœ… | βœ… | βœ… | βœ… | βœ… |
| View statistics | βœ… | βœ… | βœ… | βœ… | βœ… |
| View history/snapshots | βœ… | βœ… | βœ… | βœ… | βœ… |
| View guidelines (list/by-entity/snapshots) | βœ… | βœ… | βœ… | βœ… | βœ… |
| View guidelines (list/by-id/by-entity/snapshots) | βœ… | βœ… | βœ… | βœ… | βœ… |
| View responsible organizations of country/region/massif | βœ… | βœ… | βœ… | βœ… | βœ… |
| **Content Creation** |
| Create caves/entrances/documents/organisations/massifs | ❌ | βœ… | βœ… | βœ… | βœ… |
Expand All @@ -145,8 +144,7 @@ Roles are **not hierarchical**; for instance, an Administrator does not automati
| Update descriptions/locations/riggings/histories | ❌ | βœ… | βœ… | βœ… | βœ… |
| Update own comments | ❌ | βœ… | βœ… | βœ… | βœ… |
| Update any comment | ❌ | ❌ | ❌ | βœ… | ❌ |
| Update/rollback own guidelines | ❌ | βœ… | βœ… | βœ… | βœ… |
| Update/rollback any guideline | ❌ | ❌ | ❌ | βœ… | βœ… |
| Update/rollback any guideline (ownership not checked) | ❌ | βœ… | βœ… | βœ… | βœ… |
| Set main name for entities | ❌ | βœ… | βœ… | βœ… | βœ… |
| Move entrance to another cave | ❌ | βœ… | βœ… | βœ… | βœ… |
| Reorder descriptions/locations/riggings/histories/comments | ❌ | βœ… | βœ… | βœ… | βœ… |
Expand Down Expand Up @@ -237,17 +235,27 @@ Roles are **not hierarchical**; for instance, an Administrator does not automati
### Legislation Guidelines
Guidelines are legal/regulatory notes attached to one or more geographic entities (country, region, massif).

- **Read**: Fully public β€” list, by-entity lookup, and snapshots require no authentication
- **Read**: Fully public β€” list, single-guideline lookup by id, by-entity lookup, and snapshots require no
authentication. The by-id route is gated `['validateId']` only, and that policy rejects through `res.notFound`, so a
malformed id and a missing one are indistinguishable β€” both yield `404`. The route also returns `404` for soft-deleted
guidelines **to every role**, including Moderators: unlike the core-content `find` controllers it has no `MODERATOR`
branch revealing deleted records. That hides the *live row* only, and does not make a deleted guideline's content
unreachable β€” `get-snapshots` is public and queries `h_guideline` by `t_id` without consulting the live row or its
`isDeleted` flag, while the update trigger snapshots the pre-delete title, description and language on soft-delete. An
unauthenticated caller can therefore still read a soft-deleted guideline's text via
`GET /api/v1/guidelines/:id/snapshots`. Treat soft-deleting a guideline as unpublishing it, not as redacting it
- **Create**: Any authenticated user; no role check. At least one country, region, or massif must be referenced
- **Update**: Author, Moderator, or Administrator only β€” unlike most content, a plain user cannot edit another user's
guideline
- **Rollback**: Same rule as update (rollback mutates title/description/language, so it is treated as an update).
Guidelines are currently the only entity exposing a rollback route
- **Soft delete/restore**: Moderator or Administrator only β€” the author alone cannot delete their own guideline
- **Update**: Any authenticated user; no ownership and no role check β€” `tokenAuth` is the only gate, so a plain user can
edit another user's guideline, as with most other content
- **Rollback**: Same rule as update β€” any authenticated user, no ownership or role check. Guidelines are currently the
only entity exposing a rollback route
- **Geographic scope on update**: unlike create, `PATCH` accepts clearing every association, so a guideline can end up
attached to no country, region, or massif. The at-least-one rule is enforced on create only
- **Soft delete/restore**: Moderator or Administrator only β€” neither the author nor a plain user can delete a guideline
- **Permanent delete**: Administrator only, via `?isPermanent=1`. Performed as a two-phase delete that first clears the
country/region/massif junction rows and history
- **Asymmetry to note**: authorship grants edit rights but not delete rights, so an author can amend their guideline but
must ask a Moderator or an Administrator to remove it
- **Asymmetry to note**: editing is open to every authenticated user while deletion requires a Moderator or an
Administrator, so any user can rewrite or roll back a guideline they must ask a moderator to remove

### Responsible Organization Associations
Countries, regions, and massifs can be linked to the organizations in charge of managing them and their caves.
Expand All @@ -264,14 +272,15 @@ Countries, regions, and massifs can be linked to the organizations in charge of
associations are still returned by reads, flagged with `isDeleted` and a `redirectTo` pointer

### Owner-Based Access Control
- **Content Ownership**: Users can modify any content except other users' comments, other users' guidelines, and
documents that have modifications pending moderator approval (`modifiedDocJson` set)
- **Moderator Override**: Moderators can modify any content regardless of ownership. Administrators override ownership
only on guidelines β€” updating another user's comment, or a document with pending modifications, checks `MODERATOR`
alone and does not accept `ADMINISTRATOR` as an alternative
- **Comment Updates**: Users can update their own comments; Moderators can update any comments
- **Guideline Updates**: Users can update and roll back only their own guidelines; Moderators and Administrators can
update and roll back any guideline
- **Content Ownership**: Users can modify any content except other users' comments and documents that have modifications
pending moderator approval (`modifiedDocJson` set)
- **Moderator Override**: Moderators can modify any content regardless of ownership. There is no ownership check left for
`ADMINISTRATOR` to override β€” updating another user's comment, or a document with pending modifications, checks
`MODERATOR` alone and does not accept `ADMINISTRATOR` as an alternative
- **Comment Updates**: Users can update their own comments; Moderators can update any comments. Comments are now the only
entity whose update path is ownership-scoped
- **Guideline Updates**: Any authenticated user can update and roll back any guideline, their own or not β€” the update and
rollback controllers perform no ownership or role check

### Document Linking
Linking and unlinking are **not** gated symmetrically for caves, entrances, and massifs:
Expand Down Expand Up @@ -330,8 +339,8 @@ cannot detach it either.

### Ownership-Based Access
- Users can modify their own content
- Which role overrides ownership is per-entity, not uniform: Moderator overrides it everywhere, while Administrator
overrides it only on guidelines. See "Owner-Based Access Control" for the exceptions
- Only comments and documents pending approval are ownership-scoped on update, and both accept `MODERATOR` alone as the
override β€” `ADMINISTRATOR` is not accepted anywhere as an ownership override. See "Owner-Based Access Control"
- Organization members can manage their organization's explored caves

### Sensitive Data Protection
Expand Down Expand Up @@ -411,7 +420,8 @@ combined with per-controller role checks that were not applied uniformly β€” so

Items 1, 4 and 5 are tracked in
[#1796](https://github.com/GrottoCenter/grottocenter-api/issues/1796). Items 2 and 3 are **accepted behaviour**: in
practice Administrators are granted every group, so they cumulate Moderator powers and are not blocked.
practice Administrators are granted every group, so they cumulate Moderator powers and are not blocked. Item 6 is
**intended behaviour** introduced deliberately, recorded here because it reads as an inconsistency.

1. **Moderators can permanently delete core content.** For caves, entrances, documents, comments, descriptions,
locations, riggings, histories, organisations, and massifs, the `isPermanent` branch of the delete controller is
Expand All @@ -431,6 +441,12 @@ practice Administrators are granted every group, so they cumulate Moderator powe
the outlier, accepting either role. Tracked in #1796.
5. **Delete and restore disagree within the same entity.** `device`, `sensor-configuration` and `guideline` all accept
either role to *delete*, but only `guideline` accepts either role to *restore*. Tracked in #1796.
6. **Guidelines are writable by anyone but deletable only by Moderators or Administrators.** `guideline/update` and
`guideline/rollback` perform no ownership or role check, so any authenticated user can rewrite or roll back any
guideline, while `guideline/delete` requires Moderator or Administrator. Rollback is the sharper edge: it replaces
the live title, description, and language from an arbitrary snapshot, giving any user a one-request way to revert
another user's edits. Deliberate β€” it matches how caves, entrances, and documents are already open to any
authenticated user β€” but it leaves comments as the only ownership-scoped update path.

If any of these are corrected in code, update the matrix rows and the "Soft Deletes" section together.

Expand All @@ -443,7 +459,7 @@ If any of these are corrected in code, update the matrix rows and the "Soft Dele
- `true` - Public access (no authentication required)
- `'tokenAuth'` - Requires valid JWT token
- `'mfaEnrollmentAuth'` - Requires valid MFA enrollment token
- `['validateId']` - Public access with ID validation
- `['validateId']` - Public access with ID validation (e.g. `v1/guideline/find`, the single-guideline read route)
- `['validateId', 'tokenAuth']` - Authenticated with ID validation
- `['tokenAuth', 'validateId']` - Same pair in the opposite order, used by the responsible-organization association
routes. Policies run in sequence, so the order decides which rejection an unauthenticated request with a malformed ID
Expand Down
21 changes: 21 additions & 0 deletions api/controllers/v1/guideline/find.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const ControllerService = require('../../../services/ControllerService');
const GuidelineService = require('../../../services/GuidelineService');
const { toGuideline } = require('../../../services/mapping/converters');

module.exports = async (req, res) => {
const guidelineId = req.param('id');
const guideline = await GuidelineService.getGuidelineDetail(guidelineId);
if (!guideline || guideline.isDeleted) {
return res.notFound({
message: `Guideline of id ${guidelineId} not found.`,
});
}
return ControllerService.treatAndConvert(
req,
null,
guideline,
{ controllerMethod: 'GuidelineController.find' },
res,
toGuideline
);
};
16 changes: 0 additions & 16 deletions api/controllers/v1/guideline/rollback.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
const dayjs = require('../../../utils/dayjs');
const ControllerService = require('../../../services/ControllerService');
const GuidelineService = require('../../../services/GuidelineService');
const RightService = require('../../../services/RightService');
const { toSimpleGuideline } = require('../../../services/mapping/converters');

module.exports = async (req, res) => {
Expand Down Expand Up @@ -34,21 +33,6 @@ module.exports = async (req, res) => {
});
}

// Rollback mutates the guideline (title, description, language), so it is
// functionally an update: restrict it to the author, a moderator, or an admin.
const isAuthor = Number(rawGuideline.author) === Number(req.token.id);
const isModerator = RightService.hasGroup(
req.token.groups,
RightService.G.MODERATOR
);
const isAdmin = RightService.hasGroup(
req.token.groups,
RightService.G.ADMINISTRATOR
);
if (!isAuthor && !isModerator && !isAdmin) {
return res.forbidden('You are not authorized to roll back this guideline.');
}

// Find the specific history snapshot by comparing instants rather than doing a
// formatted-string equality on the timestamp column. The model's `id` is
// date_reviewed, whose serialized form depends on the adapter and the server
Expand Down
21 changes: 0 additions & 21 deletions api/controllers/v1/guideline/update.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ const ControllerService = require('../../../services/ControllerService');
const GuidelineService = require('../../../services/GuidelineService');
const CommonService = require('../../../services/CommonService');
const { toSimpleGuideline } = require('../../../services/mapping/converters');
const RightService = require('../../../services/RightService');

module.exports = async (req, res) => {
// Only the author or a moderator can update a guideline
const guidelineId = req.param('id');
const rawGuideline = await TGuideline.findOne(guidelineId)
.populate('countries')
Expand All @@ -17,19 +15,6 @@ module.exports = async (req, res) => {
});
}

const isAuthor = Number(rawGuideline.author) === Number(req.token.id);
const isModerator = RightService.hasGroup(
req.token.groups,
RightService.G.MODERATOR
);
const isAdmin = RightService.hasGroup(
req.token.groups,
RightService.G.ADMINISTRATOR
);
if (!isAuthor && !isModerator && !isAdmin) {
return res.forbidden('You are not authorized to update this guideline.');
}

const newTitle = req.param('title');
const newDescription = req.param('description');
const newLanguage = req.param('language');
Expand Down Expand Up @@ -85,12 +70,6 @@ module.exports = async (req, res) => {
});
}

if (countries.length === 0 && regions.length === 0 && massifs.length === 0) {
return res.badRequest({
message: 'At least one country, region, or massif must be specified.',
});
}

// Only validate entity types the caller actually changed: unchanged types are
// already-persisted (and were validated on create/previous update), so we pass
// empty arrays for them. resolveEntitiesExist short-circuits on empty arrays.
Expand Down
29 changes: 29 additions & 0 deletions api/services/GuidelineService.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const CaveService = require('./CaveService');
const NameService = require('./NameService');

module.exports = {
/**
Expand Down Expand Up @@ -29,6 +30,34 @@ module.exports = {
.populate('regions')
.populate('massifs'),

/**
* Fetch a single guideline for the public detail endpoint.
*
* Adds two hydrations on top of getGuideline that only the detail view needs
* (see toGuideline): `language`, so the response can carry its readable
* `refName` instead of the bare FK code, and the massifs' names, which live
* in the separate t_name table. Kept separate from getGuideline so the
* create/update/rollback/restore/delete responses β€” which all use the leaner
* toSimpleGuideline shape β€” are unaffected.
*
* Massif names are resolved in a single batched query via NameService rather
* than one lookup per massif, so this stays free of N+1 queries.
* @param {number} id - The ID of the guideline
* @returns {Promise<Object|null>} The guideline record or null/undefined
*/
getGuidelineDetail: async (id) => {
const guideline = await TGuideline.findOne({ id })
.populate('author')
.populate('reviewer')
.populate('countries')
.populate('regions')
.populate('massifs')
.populate('language');
if (!guideline) return guideline;
await NameService.setNames(guideline.massifs, 'massif');
return guideline;
},

/**
* Fetch all history snapshots for a given guideline ID, populating author and reviewer.
* @param {number} guidelineId - The ID of the target guideline
Expand Down
Loading