Let each team name its own OIDC group, not a global mapping

Team membership from single sign-on used to come from one env var,
TERDUT_OIDC_GROUP_MAPPINGS, matched against a team by name and creating
the team if none existed. That put the decision in the server's
environment rather than the team's own hands, needed a restart to
change, and let a typo in a team name silently create a stray team.

Each team now carries its own oidc_member_group and oidc_owner_group,
set by its owner (or an administrator) from the Members tab, or PUT
/api/teams/{teamID}/oidc-groups. The "highest role wins" rule
TERDUT_OIDC_GROUP_MAPPINGS used to apply across mappings now applies
across one team's own two fields: being in both makes somebody an
owner. The sync no longer creates a team by name; a group only ever
grants into a team that already exists.

This is a breaking change for anyone already using
TERDUT_OIDC_GROUP_MAPPINGS, deliberately not auto-migrated: an
OIDC-sourced membership is dropped at a user's next sign-in until its
team's owner re-sets the group. The README's OIDC section spells out
the migration and the risk of a visible access gap during it.

TERDUT_OIDC_ADMIN_GROUP and TERDUT_OIDC_ALLOWED_GROUPS are untouched --
only team membership moved. terdut-tui needs no change: it only reads
GET /api/teams and GET /api/teams/{id}/members, and neither response
shape moved.
This commit is contained in:
Niklas Ye
2026-09-27 11:43:57 +02:00
parent 97a4814c04
commit 5b4683febf
17 changed files with 554 additions and 129 deletions
+7
View File
@@ -146,6 +146,13 @@ function identityCard() {
fact('Created', when(t.created_at)),
fact('Members', String(t.members)),
fact('Open incidents', String(t.open_incidents)),
// Read-only here: an administrator can see why a team's OIDC-sourced
// membership looks the way it does, but setting it is the team's own
// owner's call, from the Team tab.
...(state.auth?.oidc?.enabled ? [
fact('OIDC member group', t.oidc_member_group || '—'),
fact('OIDC owner group', t.oidc_owner_group || '—'),
] : []),
),
form, err, ok,
);
+4
View File
@@ -135,6 +135,10 @@ export const addTeamMember = (id, userID, role) =>
call('POST', `/teams/${id}/members`, { body: { user_id: userID, role } });
export const removeTeamMember = (id, userID) => call('DELETE', `/teams/${id}/members/${userID}`);
// Which OIDC groups grant member and owner access to this team.
export const oidcGroups = (id) => call('GET', `/teams/${id}/oidc-groups`);
export const setOidcGroups = (id, body) => call('PUT', `/teams/${id}/oidc-groups`, { body });
export const integrations = (id) => call('GET', `/teams/${id}/integrations`);
export const createIntegration = (id, name) =>
call('POST', `/teams/${id}/integrations`, { body: { name } });
+75 -4
View File
@@ -101,8 +101,12 @@ async function load(id) {
return { members, schedule };
}
if (tab === 'members') {
const [members, users] = await Promise.all([api.teamMembers(id), allUsers()]);
return { members, users };
const [members, users, oidcGroups] = await Promise.all([
api.teamMembers(id),
allUsers(),
state.auth?.oidc?.enabled ? api.oidcGroups(id) : null,
]);
return { members, users, oidcGroups };
}
if (tab === 'escalation') {
const [members, escalation] = await Promise.all([api.teamMembers(id), api.escalation(id)]);
@@ -1046,6 +1050,73 @@ function shiftCell(m) {
: h('span', { text: 'not scheduled' });
}
// The team's own OIDC group binding, shown only on an SSO-enabled install:
// which group grants membership and which grants ownership. Read-only text
// for a member, an edit sheet for an owner — the server enforces the same
// split on the endpoint underneath.
function oidcGroupsCard() {
if (!state.auth?.oidc?.enabled) return null;
const g = data.oidcGroups || { member_group: '', owner_group: '' };
return h('div', { class: 'card' },
h('div', { class: 'card-head' },
h('h2', { text: 'Single sign-on' }),
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'Edit', onclick: openOidcGroupsEditor,
})),
h('p', { class: 'muted small' },
'Members of the group below are added to this team automatically at ',
'sign-in; members of the owner group become owners. Leave a field ',
'blank to grant nothing this way.'),
h('dl', { class: 'user-facts' },
fact('Member group', g.member_group || '—'),
fact('Owner group', g.owner_group || '—'),
),
);
}
function fact(label, value) {
return [h('dt', { text: label }), h('dd', { text: value })];
}
function openOidcGroupsEditor() {
const g = data.oidcGroups || { member_group: '', owner_group: '' };
const memberGroup = h('input', {
type: 'text', value: g.member_group, placeholder: 'e.g. sre', autofocus: true,
});
const ownerGroup = h('input', { type: 'text', value: g.owner_group, placeholder: 'e.g. sre-leads' });
const problem = h('p', { class: 'load-error', hidden: true });
const form = h('form', { class: 'stacked-form' },
h('label', {}, 'Member group ', memberGroup),
h('label', {}, 'Owner group ', ownerGroup),
h('p', { class: 'muted small' },
'A person in both becomes an owner. Whoever the group lists is kept in ',
'sync at their next sign-in — a member added by hand can still be made ',
'an owner, but not the other way round.'),
problem,
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save' })));
form.addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api.setOidcGroups(teamID, {
member_group: memberGroup.value.trim(),
owner_group: ownerGroup.value.trim(),
});
} catch (err) {
problem.textContent = err.message;
problem.hidden = false;
return;
}
closeSheet(true);
refresh();
});
openSheet(() => [h('h2', { class: 'sheet-title', text: 'Single sign-on groups' }), form]);
}
function membersCard() {
const members = data.members || [];
const owners = members.filter((m) => m.role === 'owner').length;
@@ -1090,7 +1161,7 @@ function membersCard() {
);
});
return h('div', { class: 'card' },
return [oidcGroupsCard(), h('div', { class: 'card' },
h('div', { class: 'card-head' },
h('h2', { text: 'Members' }),
isOwner() && h('button', {
@@ -1108,7 +1179,7 @@ function membersCard() {
h('th'))),
h('tbody', {}, rows)))
: h('p', { class: 'muted', text: 'Nobody is in this team.' }),
);
)];
}
// One sheet for both jobs a member's row has: who, and as what. Adding is