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)}) }) }