Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
123 commits
Select commit Hold shift + click to select a range
74973a0
Add LDAP login provider and shared, provider-customizable login form
devin-ai-integration[bot] Nov 3, 2026
b33a503
Mark test-only passwords for secret detection
devin-ai-integration[bot] Nov 3, 2026
61eb14a
CI: seed the upgrade check with the release before the auth refactor
devin-ai-integration[bot] Nov 3, 2026
79a9a0c
Show the sign-in provider in the account bubble's native tooltip
devin-ai-integration[bot] Nov 3, 2026
6b352e2
Show the sign-in provider on the TopBar account avatar
devin-ai-integration[bot] Nov 3, 2026
edb1864
Don't reveal username collisions on provider login; log them instead
devin-ai-integration[bot] Nov 3, 2026
f60c833
Remove the LDAP password-help link (LDAP_PASSWORD_HELP_URL)
devin-ai-integration[bot] Nov 3, 2026
6d57f7b
Docs: how admins keep Configure access with LDAP-only sign-in
devin-ai-integration[bot] Nov 3, 2026
ec33176
LDAP quick fixes: AD docs, CORP\user sign-in, login groups, remembere…
devin-ai-integration[bot] Nov 4, 2026
16a34d3
Share the login-response lead groups through the provider context; ma…
devin-ai-integration[bot] Nov 4, 2026
42f6513
LDAP: report groups by DN so same-named groups in different OUs stay …
devin-ai-integration[bot] Nov 4, 2026
ff0e2af
LDAP: back off after a rejected service account, check settings at st…
devin-ai-integration[bot] Nov 4, 2026
1eb17d9
Move LDAP and proxy provider tests into their plugins; limit failed l…
devin-ai-integration[bot] Nov 4, 2026
4b744de
Keep first-SuperAdmin setup open until a SuperAdmin exists; with anot…
devin-ai-integration[bot] Nov 4, 2026
2f721c5
Recheck provider-linked sessions and tokens against the directory; ke…
devin-ai-integration[bot] Nov 4, 2026
8abd1b8
Mark the locked-account test value as not a secret
devin-ai-integration[bot] Nov 4, 2026
a1a911a
Show how each account signs in and its last provider login; record pr…
devin-ai-integration[bot] Nov 4, 2026
89f2a77
Let an admin allow one provider login to relink an existing account; …
devin-ai-integration[bot] Nov 4, 2026
11c31e8
Harden relink and failed-login limits after second review wave
devin-ai-integration[bot] Nov 4, 2026
06e2eb9
Split relink cancel into its own route and rate-limit both (CodeQL)
devin-ai-integration[bot] Nov 4, 2026
a0a8242
Check direct-bind identity, refuse relinks to linked entries, limit l…
devin-ai-integration[bot] Nov 4, 2026
bead8a1
Fix wave-3 relink, group-filter and login-message findings; document …
devin-ai-integration[bot] Nov 4, 2026
98841c0
Pin relinks to the entry through renames, count pending SuperAdmin re…
devin-ai-integration[bot] Nov 4, 2026
387e6a1
Show accounts whose directory entry is gone, save LDAP group DNs in t…
devin-ai-integration[bot] Nov 4, 2026
41cbc33
Refuse the reserved 'group' username for provider accounts, record di…
devin-ai-integration[bot] Nov 4, 2026
11a4f28
Flag relinks pinned to an entry from before a directory change, check…
devin-ai-integration[bot] Nov 4, 2026
861fd1a
Don't suggest a relink when a reused name's old entry still exists; w…
devin-ai-integration[bot] Nov 4, 2026
b954abd
Per-address provider login limit counts only wrong credentials
devin-ai-integration[bot] Nov 4, 2026
85fc294
Recheck a cached missing account once it signed in again; allow relin…
devin-ai-integration[bot] Nov 4, 2026
1b5c029
Report accounts Check linked accounts couldn't check; log provider gr…
devin-ai-integration[bot] Nov 4, 2026
53cdae4
Find a reused name's old entry anywhere in the directory; refuse cros…
devin-ai-integration[bot] Nov 4, 2026
77ac285
Name accounts and MMGIS groups in provider log lines; migration runbo…
devin-ai-integration[bot] Nov 4, 2026
2c51ea2
LDAP: treat a lookup or locate hit as present, refuse relinks when un…
devin-ai-integration[bot] Nov 4, 2026
2da2f08
LDAP: search the naming context for o= trees, read groups as the serv…
devin-ai-integration[bot] Nov 4, 2026
83cd376
Address wave 9 review: scope login CSRF check to the shared form, tes…
devin-ai-integration[bot] Nov 4, 2026
225c604
Close login CSRF on /api/users/login, fold İ and Unicode forms in use…
devin-ai-integration[bot] Nov 4, 2026
f254d94
Let CORS_ORIGINS post login forms, fold usernames on both sides in SQ…
devin-ai-integration[bot] Nov 4, 2026
cb2f70d
Resolve local login username before the limiters, refuse markup in pr…
devin-ai-integration[bot] Nov 4, 2026
cd93c7b
Check enabled providers, not installed ones, before refusing local lo…
devin-ai-integration[bot] Nov 4, 2026
c7c7150
Let reset tokens of accounts linked to a disabled provider be used; n…
devin-ai-integration[bot] Nov 4, 2026
e8156a5
Fill a new or repointed provider group from linked accounts right awa…
devin-ai-integration[bot] Nov 4, 2026
59c5498
Wait for the test OpenLDAP to finish restarting before seeding it
devin-ai-integration[bot] Nov 4, 2026
4febbd4
Recheck recovered local sessions of linked accounts; disabled-provide…
devin-ai-integration[bot] Nov 4, 2026
a7518a2
Keep API tokens of accounts linked only to a disabled provider off
devin-ai-integration[bot] Nov 4, 2026
c0c641f
Wave 14: temporary ppolicy lockouts, recheck timeouts, rechecks-off l…
devin-ai-integration[bot] Nov 4, 2026
8510191
Fold DOMAIN\ in relink usernames; warn when LDAP_BIND_DN has no LDAP_…
devin-ai-integration[bot] Nov 4, 2026
a3e7c06
Cap a request's wait for a recheck at 5 s; parse escaped commas in gr…
devin-ai-integration[bot] Nov 4, 2026
f49e764
Users & Access: list provider-group members, search People by group, …
devin-ai-integration[bot] Nov 4, 2026
fbdfa8c
LDAP: count wrong passwords per directory entry, recheck against LDAP…
devin-ai-integration[bot] Nov 4, 2026
e46718f
LDAP: check extensible/approx filter terms by presence at recheck, cl…
devin-ai-integration[bot] Nov 4, 2026
a0fac1b
LDAP: recheck filter collapses wildcards and ignores negated {{userna…
devin-ai-integration[bot] Nov 4, 2026
74a947b
LDAP: a locked entry answers like a wrong password; flag group names …
devin-ai-integration[bot] Nov 4, 2026
10280df
Users & Access: Check a provider user lists the groups LDAP reports n…
devin-ai-integration[bot] Nov 4, 2026
90d6ea9
LDAP: warn about changeable subject attributes and missing subject/us…
devin-ai-integration[bot] Nov 4, 2026
cb5e10e
Groups: say when rechecks are off a new directory group fills only at…
devin-ai-integration[bot] Nov 4, 2026
3e9bcf1
Keep visitor sign-up closed while accounts are linked to a provider t…
devin-ai-integration[bot] Nov 4, 2026
5641509
Check a provider user matches accounts by directory entry (rename, re…
devin-ai-integration[bot] Nov 4, 2026
4aa8b68
LDAP: Configure notes for pending relinks and aliases, consistent gro…
devin-ai-integration[bot] Nov 4, 2026
0835ffc
LDAP: Check a provider user finds pending relinks by entry and names …
devin-ai-integration[bot] Nov 4, 2026
2c281d5
LDAP: entry without the subject attribute gets 403 not 503; retry a s…
devin-ai-integration[bot] Nov 4, 2026
aa88e3f
Accounts: warn at startup when a changed LDAP_SUBJECT_ATTRIBUTE stran…
devin-ai-integration[bot] Nov 4, 2026
32fa1d8
Wave 23 lows: subject-change wording and reasons, drawings_to matchin…
devin-ai-integration[bot] Nov 4, 2026
08b12f5
Catch LDAP subject changes between two IDs; flag relinks pinned to re…
devin-ai-integration[bot] Nov 4, 2026
656da7a
Refuse sign-ins and relink claims under a changed subject setting; ke…
devin-ai-integration[bot] Nov 4, 2026
01c02db
Run the stale-relink sweep in parallel; fix rollout doc step and subj…
devin-ai-integration[bot] Nov 4, 2026
57e5a13
LDAP: relink after a subject setting change that kept the value; dete…
devin-ai-integration[bot] Nov 4, 2026
db6904f
LDAP: per-setting heartbeats instead of last-start-wins; pinned relin…
devin-ai-integration[bot] Nov 4, 2026
bc72920
LDAP: database-clock heartbeats; direct-bind own-link relinks; refuse…
devin-ai-integration[bot] Nov 4, 2026
500f74b
LDAP: re-cased subject at the same entry keeps its account; heartbeat…
devin-ai-integration[bot] Nov 4, 2026
731dd2c
LDAP: a re-cased non-permanent subject needs an admin's relink instea…
devin-ai-integration[bot] Nov 4, 2026
aba370f
LDAP: rechecks end a link the directory matches only without case; DN…
devin-ai-integration[bot] Nov 4, 2026
a04c305
LDAP: a stored subject that is any of the entry's values still matche…
devin-ai-integration[bot] Nov 4, 2026
69b96fc
LDAP: sign-ins follow reordered multi-valued subjects; refuse another…
devin-ai-integration[bot] Nov 4, 2026
bf792ba
Relink dialog shows the entry before confirming; People 'Needs relink…
devin-ai-integration[bot] Nov 4, 2026
123fb62
AUTH_PROVIDER_NEW_ACCOUNTS for directory migrations; relink preview r…
devin-ai-integration[bot] Nov 4, 2026
5808015
Pending relinks survive reordered multi-valued subjects; refuse LDAP_…
devin-ai-integration[bot] Nov 4, 2026
2cf0dff
Users and access toolbars wrap instead of overlapping on narrow screens
devin-ai-integration[bot] Nov 4, 2026
4db562f
LDAP: refuse the service account in UPN and DOMAIN\name forms; warn w…
devin-ai-integration[bot] Nov 4, 2026
549eef4
Provider rechecks: stop requests waiting once the provider is found u…
devin-ai-integration[bot] Nov 4, 2026
102200a
Rechecks: only connection failures stop other requests waiting; LDAP:…
devin-ai-integration[bot] Nov 4, 2026
802b5c9
Rechecks: someone in too many LDAP groups loses their group roles ins…
devin-ai-integration[bot] Nov 4, 2026
87fcd30
LDAP.md: ending a leaver's access without ppolicy; People shows the l…
devin-ai-integration[bot] Nov 4, 2026
36760f4
Simultaneous first LDAP sign-ins of one person no longer get 409; a s…
devin-ai-integration[bot] Nov 4, 2026
3fcd07a
Docs: simultaneous sign-ins and the failed-login count; OpenLDAP nest…
devin-ai-integration[bot] Nov 4, 2026
bb3479d
Login forms: the button says 'Signing in…' and is disabled while a pr…
devin-ai-integration[bot] Nov 4, 2026
67d1340
LDAP: a service account that isn't an entry (OpenLDAP rootDN) is look…
devin-ai-integration[bot] Nov 4, 2026
1f18fe7
People keeps allowed relinks listed as pending; outage log no longer …
devin-ai-integration[bot] Nov 4, 2026
66c2f15
Keep not-found accounts refused while their recheck fails; cancel a r…
devin-ai-integration[bot] Nov 4, 2026
81d4214
Name the size limit in the too-many-groups sign-in log
devin-ai-integration[bot] Nov 4, 2026
e743d49
Show the time an account was first not found, so a same-day directory…
devin-ai-integration[bot] Nov 4, 2026
ec739a9
Treat form-encoded relink previews as previews; log expired relinks a…
devin-ai-integration[bot] Nov 4, 2026
311539d
Run plugin unit tests by their @unit tag instead of a hand-written CI…
devin-ai-integration[bot] Nov 4, 2026
5f91c16
Let plugins declare their CI setup in plugin.json; LDAP declares ldap…
devin-ai-integration[bot] Nov 4, 2026
53459dc
Center the provider login hint; use neutral example directory names
devin-ai-integration[bot] Nov 4, 2026
a246212
Relinking is opt-in per provider (plugin.json "relink": true); Users …
devin-ai-integration[bot] Nov 4, 2026
563f328
AUTH_LOCAL_LOGIN=admins: refuse non-admin local sign-ins with the wro…
devin-ai-integration[bot] Nov 4, 2026
3d2f21d
Bump version to 5.5.1-20261104
devin-ai-integration[bot] Nov 4, 2026
d2b2541
LDAP direct bind reads the person's entry as the service account; Che…
devin-ai-integration[bot] Nov 4, 2026
f4ab75d
Check a provider user splits typed groups at unescaped semicolons as …
devin-ai-integration[bot] Nov 4, 2026
bdf495e
LDAP plugin: ldapts ^9.2.0
devin-ai-integration[bot] Nov 4, 2026
e0e3925
Docker: Node 22 in both stages (ldapts 9.x needs Node >=22)
devin-ai-integration[bot] Nov 4, 2026
9f675e7
plugin-ci: take plugin dir/variant from manifests, validate pass-thro…
devin-ai-integration[bot] Nov 4, 2026
1e45aa7
plugin-ci: pass Playwright options after -- through unchanged
devin-ai-integration[bot] Nov 4, 2026
2283c00
Keep the Configure 'Signed in as' banner on screen: lay it out above …
devin-ai-integration[bot] Nov 4, 2026
6f9b5aa
Show the sign-in provider on the account avatar: provider icon badge …
devin-ai-integration[bot] Nov 5, 2026
67c392f
Account menu: keep the provider badge on the menu avatar only, not th…
devin-ai-integration[bot] Nov 5, 2026
51f4b5f
Merge remote-tracking branch 'origin/development' into devin/17937379…
devin-ai-integration[bot] Nov 5, 2026
36baaf6
chore: bump version to 5.5.2-20261105 [version bump]
github-actions[bot] Nov 5, 2026
4a49c31
Restore test:plugins:ci script dropped in the development merge
devin-ai-integration[bot] Nov 5, 2026
7761e0d
Account button: show a ring and brighten on hover
devin-ai-integration[bot] Nov 5, 2026
b61ee24
Configure Groups: New group dialog with member lookup; provider group…
devin-ai-integration[bot] Nov 5, 2026
429644e
New group dialog: say when the provider can't look people up instead …
devin-ai-integration[bot] Nov 5, 2026
54cfcaa
New group dialog: even spacing, clearer selected cards, duplicate-map…
devin-ai-integration[bot] Nov 5, 2026
6f77f3d
New group dialog: match existing provider groups exactly, as sign-in …
devin-ai-integration[bot] Nov 5, 2026
bd69e45
Groups: Source column, open new groups, stacked panel fields with a m…
devin-ai-integration[bot] Nov 5, 2026
6f3e08c
Avoid super-linear regexes on login input and handle Configure key-ha…
devin-ai-integration[bot] Nov 5, 2026
376ab4d
Refuse usernames over 255 characters on every route
devin-ai-integration[bot] Nov 5, 2026
8f751bb
Count username length in characters as PostgreSQL does; refuse long u…
devin-ai-integration[bot] Nov 5, 2026
a6c1a55
Configure: account menu showing permission level and its source; Supe…
devin-ai-integration[bot] Nov 5, 2026
b76f022
Account menu: whole-mission grants override layer counts; derive site…
devin-ai-integration[bot] Nov 5, 2026
dcf2615
Users & Access: count group and audience roles in the People Site rol…
devin-ai-integration[bot] Nov 5, 2026
29e0823
Configure: add a mission filter box under New Mission
devin-ai-integration[bot] Nov 5, 2026
7c77fba
Configure: dark account popover matching the main site's account menu
devin-ai-integration[bot] Nov 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 36 additions & 13 deletions .github/workflows/playwright-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ jobs:
AUTH_PROVIDERS: local
PLAYWRIGHT_TEST_UNIT_ONLY: "true"

# Every plugin's tests tagged @unit, wherever the plugin lives
- name: Run Plugin Unit Tests
run: npm run test:plugins:unit -- --project=chromium
env:
SITE_ACCESS: ${{ matrix.site_access }}
AUTH_PROVIDERS: local
PLAYWRIGHT_TEST_UNIT_ONLY: "true"

- name: Run E2E API Tests
run: npx playwright test tests/e2e/api tests/e2e/security tests/e2e/startup --project=chromium
env:
Expand All @@ -116,7 +124,10 @@ jobs:
if: matrix.site_access == 'required' && github.event_name == 'pull_request'
run: |
git fetch --no-tags --depth=200 origin ${{ github.base_ref }}
bash tests/migration/run-legacy-compat.sh origin/${{ github.base_ref }}
# Seed with the last release before the auth refactor (the commit before scripts/auth-migrate.js was added)
added=$(git log --diff-filter=A --format=%H -1 origin/${{ github.base_ref }} -- scripts/auth-migrate.js)
if [ -n "$added" ]; then legacy="$added^"; else legacy=origin/${{ github.base_ref }}; fi
bash tests/migration/run-legacy-compat.sh "$legacy"

- name: Upload test results
if: always()
Expand All @@ -136,12 +147,29 @@ jobs:

# The proxy login provider (the replacement for AUTH=csso), with the headers a
# reverse proxy would add, under legacy network trust and the default secret trust.
proxy:
plugin-ci-list:
# Plugins whose tests need an outside service or settings declare "ci" in plugin.json (scripts/plugin-ci.js)
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.list.outputs.matrix }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: List plugin CI runs
id: list
run: echo "matrix=$(node scripts/plugin-ci.js list)" >> "$GITHUB_OUTPUT"

plugin-ci:
needs: plugin-ci-list
if: needs.plugin-ci-list.outputs.matrix != '[]'
name: plugin (${{ matrix.name }})
timeout-minutes: 20
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
proxy_trust: [network, secret]
include: ${{ fromJSON(needs.plugin-ci-list.outputs.matrix) }}

services:
postgres:
Expand Down Expand Up @@ -180,7 +208,6 @@ jobs:
- name: Setup test environment
run: | # pragma: allowlist secret
cp sample.env .env || touch .env
sed -i "/^SITE_ACCESS=/d;/^AUTH_PROVIDERS=/d;/^PROXY_/d" .env
echo "NODE_ENV=test" >> .env
echo "PORT=18888" >> .env
echo "DB_HOST=localhost" >> .env
Expand All @@ -195,20 +222,16 @@ jobs:
echo "HIDE_CONFIG=false" >> .env
echo "SECRET=ci-test-session-secret-not-for-production" >> .env

- name: Run E2E Proxy Provider Tests
run: npx --no-install playwright test tests/e2e/proxy --project=chromium
- name: Run plugin tests
run: node scripts/plugin-ci.js run "$PLUGIN_DIR" "$PLUGIN_VARIANT" -- --project=chromium
env:
SITE_ACCESS: required
AUTH_PROVIDERS: '["proxy","local"]'
PROXY_TRUST: ${{ matrix.proxy_trust }}
PROXY_SECRET: ${{ matrix.proxy_trust == 'secret' && 'ci-test-proxy-secret-not-for-production' || '' }} # pragma: allowlist secret
PROXY_GROUPS: '["mmgis-users"]'
PROXY_LEAD_GROUP: mmgis-leads
PLUGIN_DIR: ${{ matrix.dir }}
PLUGIN_VARIANT: ${{ matrix.variant }}

- name: Upload test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-proxy-${{ matrix.proxy_trust }}
name: playwright-report-${{ matrix.slug }}
path: playwright-report/
retention-days: 30
1 change: 1 addition & 0 deletions .knowledge/AI-GETTING-STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ npm start
| `npm run build` | Build the main app for production |
| `npm test` | Run Playwright tests (`playwright test`) |
| `npm run test:unit` | Run unit tests only |
| `npm run test:plugins:unit` | Run plugins' `@unit` tests |
| `npm run test:e2e` | Run E2E tests only |
| `npm run test:headed` | Run Playwright tests in headed mode |
| `npm run test:debug` | Run Playwright tests in debug mode |
Expand Down
4 changes: 4 additions & 0 deletions API/connections/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const crypto = require("crypto");
const express = require("express");
const { rateLimit } = require("express-rate-limit");
const logger = require("../logger");
const { getAllowedOrigins, getRequestHost, isOriginAllowed } = require("../origin");
const { DOCS_URL, STATE_TTL_MS, MAX_PENDING_STATES, withTimeout, ConnectionRequiredError, envSecrets, scrubbed } = require("./common");
const { connectionMap, allConnections, getConnection, setupStatus } = require("./registry");
const { principalOf, storageFor, grantRecord, ownerKey, readStored, writeStored, removeStored, serialized, adoptSessionConnections, revokeQuietly } = require("./storage");
Expand Down Expand Up @@ -132,11 +133,14 @@ function renderResult(res, view) {
});
}

// Refuses only requests a browser marks as from another site: curl and scripts send no Origin and pass
function sameOriginOnly(req, res, next) {
const site = req.get("sec-fetch-site");
const origin = req.get("origin");
// The browser sets Sec-Fetch-Site itself; a proxy that rewrites Host would otherwise refuse it
if (site === "same-origin") return next();
// Origins the site trusts with credentialed requests (CORS_ORIGINS) may post from their own pages
if (origin && getAllowedOrigins().length && isOriginAllowed(origin, getRequestHost(req))) return next();
let foreign = site === "cross-site" || site === "same-site";
if (!foreign && origin) {
try {
Expand Down
6 changes: 4 additions & 2 deletions API/logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const SECRET_LOG_KEYS = new Set([
"setup_code",
"token",
"retypepassword",
"resettoken",
"reset_token",
"access_token",
"accesstoken",
"refresh_token",
Expand All @@ -59,8 +61,8 @@ function redactSecrets(key, value) {
function redactCredentials(str) {
if (typeof str !== "string") return str;
return str
.replace(/\b(access_token|refresh_token|id_token|client_secret|code_verifier|code|password|token|api_?key)=[^\s&"',;]+/gi, "$1=_redacted_")
.replace(/(["']?\b(?:access_token|refresh_token|id_token|client_secret|code_verifier|password|api_?key)["']?\s*:\s*["']?)[^\s"',}]+/gi, "$1_redacted_")
.replace(/\b(access_token|refresh_token|id_token|client_secret|code_verifier|code|password|reset_?token|token|api_?key)=[^\s&"',;]+/gi, "$1=_redacted_")
.replace(/(["']?\b(?:access_token|refresh_token|id_token|client_secret|code_verifier|password|reset_?token|api_?key)["']?\s*:\s*["']?)[^\s"',}]+/gi, "$1_redacted_")
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/g, "$1 _redacted_");
}

Expand Down
10 changes: 10 additions & 0 deletions API/permissions/models.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ const AuthIdentity = sequelize.define(
provider: { type: Sequelize.STRING, allowNull: false },
// The provider's stable user id (e.g. the OIDC "sub" claim)
subject: { type: Sequelize.STRING, allowNull: false },
// The provider's username at the last login, which may differ from the MMGIS username after a rename
username: { type: Sequelize.STRING, allowNull: true },
last_login_at: { type: Sequelize.DATE, allowNull: true },
// The provider's subject setting when linked (e.g. LDAP_SUBJECT_ATTRIBUTE), so a later change is noticed
subject_setting: { type: Sequelize.STRING, allowNull: true },
user_id: {
type: Sequelize.INTEGER,
allowNull: false,
Expand Down Expand Up @@ -158,6 +163,11 @@ const up = async () => {
`CREATE UNIQUE INDEX IF NOT EXISTS long_term_tokens_token_hash ON long_term_tokens (token_hash);`,
"index long_term_tokens.token_hash"
);
for (const col of ["username varchar(255) NULL", "last_login_at TIMESTAMPTZ NULL", "subject_setting varchar(255) NULL"])
await runQuery(
`ALTER TABLE IF EXISTS auth_identities ADD COLUMN IF NOT EXISTS ${col};`,
`add auth_identities.${col.split(" ")[0]} column`
);
// Owning mission for data resources (null = global, SuperAdmin/Data Admin managed)
await runQuery(
`ALTER TABLE IF EXISTS geodatasets ADD COLUMN IF NOT EXISTS owner_mission_uuid UUID NULL;`,
Expand Down
64 changes: 48 additions & 16 deletions API/permissions/principal.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ async function cached(key, build) {

async function userPrincipal(userId) {
return cached(`user:${userId}`, async () => {
const { User, AuthGroupMember } = models();
const { User, AuthGroup, AuthGroupMember } = models();
const user = await User.findOne({
where: { id: userId },
attributes: ["id", "username"],
Expand All @@ -90,7 +90,19 @@ async function userPrincipal(userId) {
attributes: ["group_id"],
raw: true,
});
const groupIds = memberships.map((m) => m.group_id);
// Groups whose members come from a provider that isn't in AUTH_PROVIDERS grant nothing until it's back
const groupIds =
memberships.length > 0
? (
await AuthGroup.findAll({
where: { id: memberships.map((m) => m.group_id) },
attributes: ["id", "source"],
raw: true,
})
)
.filter((g) => g.source === "local" || siteAccess.hasProvider(g.source))
.map((g) => g.id)
: [];
const subjects = [
["user", user.id],
["audience", "authenticated"],
Expand Down Expand Up @@ -211,24 +223,36 @@ function localAccountAllowed(p) {
return !siteAccess.localAdminsOnly() || require("./policy").isAdministrator(p);
}

// Accounts created by a login provider follow that provider; the rest are local accounts
async function accountAllowed(p) {
const links = await models().AuthIdentity.findAll({
where: { user_id: p.userId },
attributes: ["provider"],
raw: true,
});
if (links.length === 0) return localAccountAllowed(p);
return links.some((l) => siteAccess.hasProvider(l.provider));
// Accounts linked to an enabled login provider follow that provider. An account whose links are all
// to providers removed from AUTH_PROVIDERS counts as a local account only for a local session (it
// got one with an MMGIS password, e.g. from a reset link); its tokens stay off until a provider is back
const providerLinks = (userId) =>
models().AuthIdentity.findAll({ where: { user_id: userId }, attributes: ["provider"], raw: true });

async function accountAllowed(p, localSession = false) {
const links = await providerLinks(p.userId);
const enabled = links.filter((l) => siteAccess.hasProvider(l.provider));
if (links.length === 0 || (enabled.length === 0 && localSession)) return localAccountAllowed(p);
for (const l of enabled) if (await require("./providers").stillLinked(p.userId, l.provider)) return true;
return false;
}

// The providers of an account that are all out of AUTH_PROVIDERS, so its tokens can't work for now; else []
async function tokensOffFor(userId) {
const links = await providerLinks(userId);
return links.some((l) => siteAccess.hasProvider(l.provider)) ? [] : [...new Set(links.map((l) => l.provider))];
}

async function tokenPrincipal(authorization) {
const raw = String(authorization).replace(/^Bearer:?\s+/i, "").trim();
if (!raw) return null;
const row = await findToken(raw);
if (!row || row.created_by_user_id == null || tokenExpired(row)) return null;
const owner = await userPrincipal(row.created_by_user_id);
if (!owner || !(await accountAllowed(owner))) return null;
// Read again: the provider recheck in accountAllowed may have changed the owner's groups
const creator = await userPrincipal(row.created_by_user_id);
if (!creator || !(await accountAllowed(creator))) return null;
if (!creator) return null;
const { LongTermTokens } = models();
if (
!row.last_used_at ||
Expand Down Expand Up @@ -264,10 +288,17 @@ async function resolvePrincipal(req) {
}
const session = req.session || {};
if (session.uid != null) {
const p = await userPrincipal(session.uid);
// Sessions started through a login provider follow that provider; the rest are local logins
const allowed =
p && (session.provider ? siteAccess.hasProvider(session.provider) : localAccountAllowed(p));
// Sessions started through a login provider follow that provider
const linked =
!session.provider ||
(siteAccess.hasProvider(session.provider) &&
(await require("./providers").stillLinked(session.uid, session.provider)));
let p = linked ? await userPrincipal(session.uid) : null;
// A local session of an account that has since been linked to an enabled provider (or whose
// provider came back) follows that provider's rechecks, like the account's tokens
const allowed = p && (session.provider ? true : await accountAllowed(p, true));
// Read again: that recheck may have changed the account's groups
if (allowed && !session.provider) p = (await userPrincipal(session.uid)) || p;
if (allowed) {
// Signed in locally while the proxy also identifies the request: like AUTH=csso, the
// proxy user stays the identity and the local account's access is added on top
Expand Down Expand Up @@ -329,5 +360,6 @@ module.exports = {
tokenExpired,
unusableLegacyToken,
scrubLegacyTokenColumn,
tokensOffFor,
siteAccess,
};
Loading
Loading