People → Sign-in shows single sign-on, Active Directory and SCIM, and the directory's last sync. E sets up Entra ID from the tenant and client IDs; D sets up Active Directory, tried from the control plane first: each server bound, people who may sign in, members of each mapped group. The secrets go into the store as store:auth/..., sealed by the master key, and the CA into the settings, so no control plane needs a file. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
171 lines
5.4 KiB
Go
171 lines
5.4 KiB
Go
package adminapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// AuthAdmin is what the sign-in endpoints need: how people sign in now, a
|
|
// directory's settings tried before they are kept, and where the secrets
|
|
// those settings name are kept for every control plane.
|
|
type AuthAdmin struct {
|
|
Status func() AuthView
|
|
// TestLDAP tries an auth.ldap block, as YAML, binding with password
|
|
// when one is given rather than the one the block names.
|
|
TestLDAP func(ctx context.Context, settings, password string) ([]CheckView, error)
|
|
Secrets interface {
|
|
SetClusterSecret(ctx context.Context, name, value string) error
|
|
}
|
|
Log *slog.Logger
|
|
}
|
|
|
|
// SetAuth enables the sign-in endpoints.
|
|
func (a *API) SetAuth(x *AuthAdmin) { a.auth = x }
|
|
|
|
// AuthView is how people sign in on this gateway.
|
|
type AuthView struct {
|
|
SignupOpen bool `json:"signup_open"`
|
|
// OIDC is single sign-on through an identity provider: Entra ID,
|
|
// Keycloak, Okta.
|
|
OIDC *OIDCView `json:"oidc,omitempty"`
|
|
// LDAP is sign-in checked by a directory: Active Directory.
|
|
LDAP *LDAPView `json:"ldap,omitempty"`
|
|
// SCIM is whether a provider may keep accounts in step.
|
|
SCIM bool `json:"scim"`
|
|
// BaseURL is the portal's address, when the settings say it: what a
|
|
// provider's redirect address is made from.
|
|
BaseURL string `json:"base_url,omitempty"`
|
|
}
|
|
|
|
// OIDCView is auth.oidc as it is in force.
|
|
type OIDCView struct {
|
|
Issuer string `json:"issuer"`
|
|
ClientID string `json:"client_id"`
|
|
Label string `json:"label,omitempty"`
|
|
CreateAccounts bool `json:"create_accounts"`
|
|
NoPasswords bool `json:"no_passwords"`
|
|
}
|
|
|
|
// LDAPView is auth.ldap as it is in force, and how its last sync went.
|
|
type LDAPView struct {
|
|
URLs []string `json:"urls"`
|
|
BaseDN string `json:"base_dn"`
|
|
BindDN string `json:"bind_dn"`
|
|
Label string `json:"label,omitempty"`
|
|
// Off is why this control plane could not use the settings, when it
|
|
// could not; local accounts still sign in.
|
|
Off string `json:"off,omitempty"`
|
|
LastSync *DirectorySyncView `json:"last_sync,omitempty"`
|
|
}
|
|
|
|
// DirectorySyncView is the last time the leader read the directory.
|
|
type DirectorySyncView struct {
|
|
At time.Time `json:"at"`
|
|
People int `json:"people"`
|
|
Accounts int `json:"accounts"`
|
|
Suspended int `json:"suspended"`
|
|
Reinstated int `json:"reinstated"`
|
|
RolesChanged int `json:"roles_changed"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// CheckView is one step of trying a directory.
|
|
type CheckView struct {
|
|
Step string `json:"step"`
|
|
OK bool `json:"ok"`
|
|
Detail string `json:"detail"`
|
|
}
|
|
|
|
// LDAPTestRequest is an auth.ldap block to try, as YAML, and the service
|
|
// account's password when it is not kept yet.
|
|
type LDAPTestRequest struct {
|
|
Settings string `json:"settings"`
|
|
Password string `json:"password,omitempty"`
|
|
}
|
|
|
|
// LDAPTestView is how trying the directory went.
|
|
type LDAPTestView struct {
|
|
OK bool `json:"ok"`
|
|
Checks []CheckView `json:"checks"`
|
|
}
|
|
|
|
// SecretRequest is a secret for the store to keep.
|
|
type SecretRequest struct {
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
// authSecrets are the secrets an operator may give through the API, and
|
|
// the reference the settings then name each by.
|
|
var authSecrets = map[string]bool{"ldap-bind": true, "oidc-client-secret": true, "scim-token": true}
|
|
|
|
// AuthSecretRef is the reference auth's settings name a kept secret by.
|
|
func AuthSecretRef(name string) string { return "store:auth/" + name }
|
|
|
|
func (a *API) registerAuth(mux Mux) {
|
|
need := func(w http.ResponseWriter) bool {
|
|
if a.auth == nil {
|
|
writeError(w, http.StatusNotImplemented, "this control plane has no accounts")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
mux.HandleFunc("GET /api/v1/auth", func(w http.ResponseWriter, r *http.Request) {
|
|
if !need(w) {
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, a.auth.Status())
|
|
})
|
|
mux.HandleFunc("POST /api/v1/auth/ldap/test", func(w http.ResponseWriter, r *http.Request) {
|
|
if !need(w) {
|
|
return
|
|
}
|
|
var req LDAPTestRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
checks, err := a.auth.TestLDAP(r.Context(), req.Settings, req.Password)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
v := LDAPTestView{OK: len(checks) > 0, Checks: checks}
|
|
for _, c := range checks {
|
|
v.OK = v.OK && c.OK
|
|
}
|
|
writeJSON(w, http.StatusOK, v)
|
|
})
|
|
mux.HandleFunc("PUT /api/v1/auth/secrets/{name}", func(w http.ResponseWriter, r *http.Request) {
|
|
if !need(w) {
|
|
return
|
|
}
|
|
name := r.PathValue("name")
|
|
if !authSecrets[name] {
|
|
writeError(w, http.StatusNotFound, "no such secret: ldap-bind, oidc-client-secret or scim-token")
|
|
return
|
|
}
|
|
var req SecretRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.Value) == "" {
|
|
writeError(w, http.StatusBadRequest, "an empty secret")
|
|
return
|
|
}
|
|
if err := a.auth.Secrets.SetClusterSecret(r.Context(), "auth/"+name, req.Value); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if a.auth.Log != nil {
|
|
// What changed and who changed it; never the value.
|
|
a.auth.Log.Info("a sign-in secret was set", "secret", name, "by", actorOf(r))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"ref": AuthSecretRef(name)})
|
|
})
|
|
}
|