Repository navigation
Auth refactor: SITE_ACCESS/AUTH_PROVIDERS, auth provider plugins, scoped role bindings, groups, token v2 - #1139
devin-ai-integration[bot] wants to merge 49 commits into
Conversation
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…red access Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…gated navigation, token scope picker Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…e defaults, account deletion with tokens, healthcheck under required access Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…routes; existing tests follow the new access rules Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
✅ Version Already UpdatedThis PR includes a manual version update to No automatic version bump needed. |
…undeclared backend plugin routes Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…setTimes Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…stalls Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…er IPv4; allowlist test fixtures Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…accepts a login-required refusal Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
… client addresses; token test matches by id Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…tenance DB Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…sites show the unauthorized page instead of the local login form Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…havior Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…n /configure; restore CRLF in websocket.js; migration guide per previous AUTH setup Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…s, missions, site-wide; grant dialog with layer picker) Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…s dropdown in the side panel; style UA_v1 tables like Users Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…k header in side panels Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
… clarify Site-wide audience descriptions Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
… All/None, colored site-role badges; drop card wrappers to match Users page Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…el view radio from grants Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…on bars Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
…actions Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
There was a problem hiding this comment.
Note
Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.
Devin Review found 7 potential issues.
1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)
| const rows = await RoleBinding.findAll({ | ||
| where: { subject_type: "user", role: "superadmin", scope_type: "global" }, | ||
| attributes: ["subject_id"], | ||
| raw: true, | ||
| }); | ||
| return rows.map((r) => parseInt(r.subject_id)); |
There was a problem hiding this comment.
🔴 Duplicate bindings undermine last-admin safeguard
When a user has duplicate SuperAdmin bindings, superAdminUserIds counts rows instead of distinct users. Deleting that account can leave the site without an administrator.
Learn more
PostgreSQL treats NULL values as distinct in the composite role-binding index, so repeated global grants can create multiple rows for one user. The account deletion guard checks the length of this list before deleting every binding for the account; two rows for the sole administrator pass the check. remove account then deletes all of that user's bindings.
Example: Grant the sole administrator the global SuperAdmin role twice using the Access page; both rows can exist. Delete that administrator; superAdminUserIds() returns two entries with the same ID and allows deletion.
Recommended fix: Count distinct administrator user IDs in the last-admin guard, and enforce uniqueness for bindings with nullable scope fields (for example, separate partial unique indexes per scope). Test duplicate grants and deletion of the only administrator.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed in c7cb257: superAdminUserIds() counts each user once, and removing a SuperAdmin row counts that user's own rows, so two rows for one user no longer count as two SuperAdmins.
| const resource = mission_uuid | ||
| ? { mission: mission_uuid, layers: layer_uuid ? [layer_uuid] : [] } | ||
| : null; | ||
| for (const cap of policy.ROLES[role].capabilities) | ||
| if (!policy.can(actor, cap, policy.MISSION_SCOPED.has(cap) ? resource : null)) | ||
| throw new BindingError("You cannot grant a role with more access than your own."); |
There was a problem hiding this comment.
🟡 Nested layer grants reject eligible editors
When a layer editor grants access to a child, addBinding checks only the child's UUID. The editor's parent-layer binding cannot match, so eligible delegation fails.
Learn more
A layer binding applies to the entire subtree. Authorization for edits uses layer resolver, which includes all ancestor UUIDs in the resource chain. addBinding verifies the layer exists but constructs a chain with only the child UUID, rejecting a parent-bound editor's legitimate grant.
Example: An editor bound to folder F can edit layer L inside F. When they try to grant layer_editor on L, policy.can sees only L and denies the grant because their binding names F.
Recommended fix: Use the already-validated missions.layerChain(config.layers, layer_uuid) as the resource's layers array, and cover nested delegation in tests.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed in c7cb257: addBinding now checks the grant against the layer's whole ancestor chain (missions.layerChain), so someone who can edit a folder can grant on its child layers, and still nothing outside it.
| const role = body.role; | ||
| if (role === "superadmin") { | ||
| await bindings.addBinding(actor, userBinding("superadmin", "global")); | ||
| } else if (role === "mission_admin" || role === "user") { | ||
| await remove((b) => b.role === "superadmin" && b.scope_type === "global"); | ||
| await bindings.setUserMissionRole( | ||
| actor, | ||
| id, | ||
| "mission_admin", | ||
| role === "mission_admin" && Array.isArray(body.missions_managing) | ||
| ? body.missions_managing.filter((m) => typeof m === "string") | ||
| : [] | ||
| ); |
There was a problem hiding this comment.
🔴 Failed account update leaves partial roles
When a requested mission is invalid, updateUser removes old bindings before setUserMissionRole rejects it. The response reports failure, but the user's earlier grants remain deleted.
Learn more
Account updates rewrite persisted role bindings. setUserMissionRole removes old mission grants before attempting each new grant, and an unknown mission causes addBinding to throw. Because no transaction wraps the update, the failed request preserves any prior deletions or successful additions.
Example: An admin changes a user's missions from A to B and Typo; the A binding is removed and B may be granted, then Typo fails. The UI reports failure even though the user's permissions changed.
Recommended fix: Validate all requested missions and grants before modifying the account, then apply email and binding changes in one transaction. Roll back all mutations on failure.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed in c7cb257: updateUser now checks the role, every mission name and the delegation limits before changing anything, and the email is written only after the role changes succeed. Covered by the e2e test "a rejected account update changes nothing" in tests/e2e/auth/permission-matrix.spec.js.
| if ( | ||
| Array.isArray(token.missions) && | ||
| resource && | ||
| resource.mission && | ||
| !token.missions.includes(resource.mission) | ||
| ) | ||
| return false; |
There was a problem hiding this comment.
🟥 Mission-limited tokens retain unrestricted global powers
A token limited to mission A still passes policy.can for global capabilities because the mission limit applies only when a resource carries a mission. A holder can use that token to modify site-wide datasets or create missions despite its mission restriction.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed in c7cb257: a token limited to missions now only reaches mission capabilities inside those missions, plus site-wide reads; mission.create, account, token and other site-wide capabilities are refused. Covered by unit tests in tests/unit/permissions-policy.spec.js and the e2e test "mission-limited tokens cannot use site-wide capabilities".
| }) | ||
| .catch(next); | ||
| }; | ||
| } |
There was a problem hiding this comment.
There was a problem hiding this comment.
Fixed in c7cb257: on public/optional sites, requests other than GET/HEAD through the adjacent-server proxies now go through ensureAdmin; reads still follow the site gate. Covered by tests/e2e/auth/guest-access.spec.js (POST/PUT/DELETE on /stac, /tipg, /titiler, /titilerpgstac).
| * User login | ||
| */ | ||
| router.post("/login", authLimiter, function (req, res) { | ||
| router.post("/login", authLimiter, authorize.public("login and account self-service"), function (req, res) { |
There was a problem hiding this comment.
There was a problem hiding this comment.
Fixed in c7cb257: /api/users/login now refuses password login unless local is in AUTH_PROVIDERS. The page-reload token path is unchanged.
| // Editing any layer in a mission implies viewing that mission | ||
| if (cap === "mission.view" && resource && resource.mission) | ||
| return bindings.some( | ||
| (b) => | ||
| ROLE_CAPS[b.role] && | ||
| ROLE_CAPS[b.role].has(cap) && | ||
| b.scope.startsWith(`layer:${resource.mission}:`) | ||
| ); |
There was a problem hiding this comment.
🟥 Restricted mission configs exposed through layer editor role
A layer-scoped editor receives mission.view for their mission. The config load returns the full mission configuration rather than a layer-filtered view, exposing other layers in that mission.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Leaving this as is, by design: editing a layer implies viewing that mission, the same as a Viewer there. This model has no per-layer view restriction (layer scopes only limit editing), and the Configure editor needs the whole layer tree to show where the editable layers sit. Mission isolation still holds: a layer editor can't read other missions (see "mission configs are readable only inside granted missions").
| * User login | ||
| */ | ||
| router.post("/login", authLimiter, function (req, res) { | ||
| router.post("/login", authLimiter, authorize.public("login and account self-service"), function (req, res) { |
There was a problem hiding this comment.
There was a problem hiding this comment.
Fixed in c7cb257: /api/users/login now refuses password login unless local is in AUTH_PROVIDERS. The page-reload token path is unchanged.
| Array.isArray(token.missions) && | ||
| resource && | ||
| resource.mission && | ||
| !token.missions.includes(resource.mission) | ||
| ) | ||
| return false; | ||
| } | ||
| if (MISSION_SCOPED.has(cap) && !(resource && resource.mission)) { |
There was a problem hiding this comment.
There was a problem hiding this comment.
Fixed in c7cb257: a token limited to missions now only reaches mission capabilities inside those missions, plus site-wide reads; mission.create, account, token and other site-wide capabilities are refused. Covered by unit tests in tests/unit/permissions-policy.spec.js and the e2e test "mission-limited tokens cannot use site-wide capabilities".
- Bearer tokens keep their limits when sent with a session; a token from another user is refused - Mission-limited tokens only reach their missions plus site reads - Account updates validate everything before changing anything - SuperAdmin removals lock the SuperAdmin rows so one always remains - Layer grants check the whole ancestor chain; duplicate SuperAdmin rows count once - Renaming a mission refreshes derived account mission lists - Password login requires the local provider - Shared mission files need a signed-in user; proxy writes need an admin on open sites Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
Co-Authored-By: tariq.k.soliman <tariqksoliman@gmail.com>
|



Purpose
AUTH=off|none|local|cssosetting with explicitSITE_ACCESS(what a visitor experiences) andAUTH_PROVIDERS(how people log in).111/110/001permission string with named capabilities and roles granted to users, groups or audiences at global, mission or layer-subtree scope, checked in one place (can()/authorize()).authplugin type for login providers, withlocalandcssoas the first core providers.Proposed Changes
SITE_ACCESS=public|optional|required(no default) andAUTH_PROVIDERS. The server refuses to start whileAUTHis set and prints the exact replacement (API/permissions/siteAccess.js)..env,sample.env,ENVs.mdupdated.API/permissions/:policy.js(capabilities; rolessuperadmin,data_admin,mission_admin,layer_editor,viewer; allow-only),principal.js(session / provider identity / bearer token / guest),authorize.js(authorize(cap, resolver),authorize.public(reason),authorize.inHandler),bindings.js(grant/revoke, no granting above your own access, always at least one SuperAdmin),missions.js(stable mission UUIDs: kept through rename, new on clone; layer UUID ancestry),models.js.plugins/core/auth/<Name>/,"type": "auth"), registry inAPI/permissions/providers.js. Providers must be core plugins with"tier": "core","overridable": false; providers in other containers are refused at startup. A provider can exportidentify(req),routes(router, ctx)(mounted at/api/auth/<id>, route permissions enforced like core routes),validate(env)andwarnings(env).ctx.completeLogin()linksprovider + subjectto an account in the newauth_identitiestable (never by matching an existing username) and syncs membership of groups sourced from that provider.AUTH_PROVIDERSonly accepts discovered providers.core/auth/Local: username/password accounts (routes still served by the Users backend at/api/users).core/auth/CSSO: the existingX-Sub/X-Groups/X-Sessionheader login, moved out ofscripts/server.js, with the optionalCSSO_PROXY_SECRETcheck.ldap,earthdata,keycloak,cognitoare reserved names for future providers; setting one fails startup with "not supported yet".missions,auth_groups,auth_group_members,role_bindings,auth_identities; token v2 columns;owner_mission_uuidon geodatasets/datasets (unused for now)./apiroute declares its capability; startup fails on an undeclared route. URL-suffix allowlists and111/110checks removed; legacy values are only read by the one-time import. Adjacent-server proxies map GET →*.read, other methods →*.write.s.app.use) still load; startup logs a warning listing those routes. Existing setup helpers (ensureUser,stopGuests, etc.) are unchanged.data_admin. Layer editors save through the per-layer routes;/api/configure/upsertneedsmission.config.edit.Missions/sharedreadable by signed-in users.npm run auth:migrate -- --dry-run/npm run auth:migrate: imports legacy permissions into bindings (also runs once on startup); never writesusernameorpassword; idempotent. Legacy columns kept so rolling back still works./api/access/*,plugins/core/backend/Access): groups and members, user/group/audience bindings with scope pickers, effective access with where it comes from. Users page edits roles directly. Navigation is gated by capabilities. Token page gets scope and mission pickers./api/utils/healthcheckanswers when login is required; API routes answer guests on a login-required site with a JSON 403.docs/pages/Migration/auth-access/auth-access.md), Auth Providers section inplugins/README.md, adjacent servers must only be reachable through MMGIS' proxy, spec 001 updated.Issues
Testing
tests/unit/permissions-*.spec.js): every role/capability/scope, groups and audiences, layer inheritance, token intersections, last-SuperAdmin, property checks (token never exceeds creator, removing a binding never adds access); provider discovery (core-only, non-overridable, duplicate ids, broken plugins), reserved provider names, CSSO header parsing and proxy secret, account linking never taking over an existing username, provider group sync; plugin route warnings. Full unit suite: 1528 passed.tests/e2e/auth/permission-matrix.spec.js(real API,SITE_ACCESS=required): two missions with folder/child/sibling layers; SuperAdmins, mission admin, layer editor, viewer, plain user, data admin, group member; mission list filtering and config isolation, layer-subtree writes,/upsertlimits, site-wide operations denied, group grant/revoke, last-SuperAdmin, read-only and mission-limited tokens, creator demotion, token expiry, guests denied.tests/migration/run-legacy-compat.sh: seeds 11 accounts (111, 110 with one/several missions, viewers, mixed case, email-like, Unicode, long password, demoted admin) onorigin/development, runs the dry run, starts this branch on the same DB: 11 password hashes byte-identical, 44 login probes (right/wrong password, changed-case and trailing-space usernames) identical to before. Runs in CI for PRs.tests/e2e/{api,security,startup,auth}on a freshmmgis-testDB, 0 failures:required173 passed,optional149 passed,public145 passed (rest skipped by mode). CI (test (local),test (off), including the upgrade check) passes.AUTH_PROVIDERS=["local","csso"]withCSSO_PROXY_SECRETset:/api/access/meis anonymous without headers or with a wrong secret, and anexternal/cssoprincipal with the reported groups with the right secret. External Agent and Workflows plugins from MMGIS-Plugins load, and startup lists their undeclared routes as warnings.Upgrade note: deployments must replace
AUTHbefore starting this version (table inENVs.mdand the migration page) and should runnpm run auth:migrate -- --dry-runfirst.Link to Devin session: https://nasa-jpl-demo.devinenterprise.com/sessions/8608a14efd934ae6b7f1f1695ce0b347
Open in Devin Desktop: https://nasa-jpl-demo.devinenterprise.com/desktop/session/8608a14efd934ae6b7f1f1695ce0b347?variant=devin
Requested by: @tariqksoliman