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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user