Sign in through an OpenID Connect provider, and from a terminal
terdut can now sign people in through any OIDC provider (written against Authentik), and let groups at the provider decide who may sign in, which teams they belong to and whether they administer the install. Password login keeps working alongside it; TERDUT_PASSWORD_LOGIN=false turns it off, and is refused at startup unless SSO is configured. With no TERDUT_OIDC_* setting nothing changes, so every existing install behaves as before. Identity is (issuer, subject), never email or username: those are mutable at the provider and a recycled address must not inherit an account. An existing user is linked by email only when the provider marks it verified, or TERDUT_OIDC_TRUST_EMAIL is set, which Authentik needs. Group grants are marked source='oidc' on team_members and users, and the sync changes only those rows. Hand-made memberships and administrators are left alone, and the sync bypasses the last-owner and last-admin guards because the provider is the source of truth for what it grants. Editing managed access by hand is refused with 409, since the next sign-in would undo it. The web UI badges it as SSO and disables the controls. Groups are read only at sign-in, so an SSO session carries a hard ceiling (sessions.max_expires_at, 12h by default) that sliding never extends. There is no refresh token, which means API keys of somebody removed at the provider stay valid until an administrator disables the user. That is accepted and documented, not fixed. A client with no browser, the TUI over SSH, signs in with a device code run by terdut itself (POST /api/oidc/device and /device/token), so the terminal never talks to the provider and ends up with the ordinary terdut_session cookie. Only a browser session can approve a code; an API key cannot. /device?code= sends a signed-out visitor through sign-in and back, which is what oidc_logins.next is for. oauth2 is pinned to v0.36.0: v0.37 needs Go 1.26 and the Dockerfile builds on 1.25. Migrations 011 and 012 add tables and defaulted columns only.
This commit is contained in:
+36
-6
@@ -21,7 +21,7 @@ func handleListTeams(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
caller, _ := userFromContext(r.Context())
|
||||
rows, err := db.QueryContext(r.Context(), `
|
||||
SELECT t.id, t.name, t.created_at, m.role
|
||||
SELECT t.id, t.name, t.created_at, m.role, m.source
|
||||
FROM teams t
|
||||
JOIN team_members m ON m.team_id = t.id
|
||||
WHERE m.user_id = $1
|
||||
@@ -36,7 +36,7 @@ func handleListTeams(db *sql.DB) http.HandlerFunc {
|
||||
for rows.Next() {
|
||||
var t models.Team
|
||||
var created int64
|
||||
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role); err != nil {
|
||||
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role, &t.Source); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func handleUserTeams(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(r.Context(), `
|
||||
SELECT t.id, t.name, t.created_at, m.role
|
||||
SELECT t.id, t.name, t.created_at, m.role, m.source
|
||||
FROM teams t
|
||||
JOIN team_members m ON m.team_id = t.id
|
||||
WHERE m.user_id = $1
|
||||
@@ -98,7 +98,7 @@ func handleUserTeams(db *sql.DB) http.HandlerFunc {
|
||||
for rows.Next() {
|
||||
var t models.Team
|
||||
var created int64
|
||||
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role); err != nil {
|
||||
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Role, &t.Source); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
@@ -255,7 +255,7 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(r.Context(), `
|
||||
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at,
|
||||
SELECT m.team_id, m.user_id, u.username, m.role, m.joined_at, m.source,
|
||||
u.ntfy_topic IS NOT NULL AND u.ntfy_topic <> '',
|
||||
u.disabled_at IS NOT NULL,
|
||||
GREATEST(
|
||||
@@ -280,7 +280,7 @@ func handleListTeamMembers(db *sql.DB) http.HandlerFunc {
|
||||
var m memberStatus
|
||||
var joined, lastActive int64
|
||||
var hasTopic, disabled bool
|
||||
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined,
|
||||
if err := rows.Scan(&m.TeamID, &m.UserID, &m.Username, &m.Role, &joined, &m.Source,
|
||||
&hasTopic, &disabled, &lastActive, &m.OnCall, &m.NextShift); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
@@ -343,6 +343,14 @@ func handleAddTeamMember(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if managed, err := isSSOManagedMember(r.Context(), db, teamID, req.UserID); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
} else if managed {
|
||||
respond(w, http.StatusConflict, errResp(ssoManagedMsg))
|
||||
return
|
||||
}
|
||||
|
||||
// Demoting the last owner is removing them by another route: the team
|
||||
// would have nobody who can edit it.
|
||||
if req.Role == models.RoleMember {
|
||||
@@ -392,6 +400,14 @@ func handleRemoveTeamMember(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if managed, err := isSSOManagedMember(r.Context(), db, teamID, userID); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
} else if managed {
|
||||
respond(w, http.StatusConflict, errResp(ssoManagedMsg))
|
||||
return
|
||||
}
|
||||
|
||||
last, err := isLastTeamOwner(r.Context(), db, teamID, userID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
@@ -416,6 +432,20 @@ func handleRemoveTeamMember(db *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// ssoManagedMsg is the refusal for editing access that single sign-on owns.
|
||||
const ssoManagedMsg = "this membership is managed by single sign-on; change the user's groups in the identity provider"
|
||||
|
||||
// isSSOManagedMember reports whether the membership comes from the group sync.
|
||||
// Editing it here would be undone at the person's next sign-in, so it is refused
|
||||
// instead of appearing to work.
|
||||
func isSSOManagedMember(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) {
|
||||
var managed bool
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT EXISTS (SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2 AND source = 'oidc')",
|
||||
teamID, userID).Scan(&managed)
|
||||
return managed, err
|
||||
}
|
||||
|
||||
func isLastTeamOwner(ctx context.Context, db *sql.DB, teamID, userID int64) (bool, error) {
|
||||
var last bool
|
||||
err := db.QueryRowContext(ctx, `
|
||||
|
||||
Reference in New Issue
Block a user