The hourly release of ADR 0229's brake still ended in the mesh acting alone on a mistake. A consumer now stays active until the same unasked set holds for five passes, waits for a person past three or half of those held, is disabled and marked rather than withdrawn, comes back as it was when asked again, and is deleted only through the provider's delete tool. The backend keeps the mark, so a restart forgets nothing and finds what was withdrawn before.
382 lines
12 KiB
Go
382 lines
12 KiB
Go
package main
|
|
|
|
// What the `oidc-client` provision means in Keycloak: one confidential OpenID Connect client per
|
|
// consumer, in the realm this module serves, under the name and secret the mesh gave both ends.
|
|
// Ported from oidc.ts, behaviour for behaviour.
|
|
//
|
|
// **The client id and the secret are the mesh's, not Keycloak's (novox/hq ADR 0048).** The mesh
|
|
// derives the consumer's identity (`as`) and hands it to both ends, and mints the secret, which this
|
|
// sets as the client's secret. Keycloak generates neither.
|
|
//
|
|
// **Where the consumer's browser comes back to is the consumer's to say.** Its contribution carries
|
|
// `callback` (a path) and the mesh composes its endpoint's names into `name` (public) and
|
|
// `internal-name` (private network) exactly as it does for a route (novox/hq ADR 0056, 0138).
|
|
//
|
|
// **Only what the mesh made is touched.** A client this module creates carries the attribute
|
|
// `mesh.provisioned=true`. A client with the same id that lacks the mark is somebody else's: it is
|
|
// refused, never adopted, never updated, never deleted.
|
|
//
|
|
// **Retired is the client disabled and marked** (novox/hq ADR 0230). `enabled: false` — Keycloak then
|
|
// refuses the client's authorization and token requests, so nobody signs in through it — and the
|
|
// attributes `mesh.retired` (when) and `mesh.retired-why` (why). Its secret, redirects, mappers and
|
|
// every other setting are kept, so asked for again it is enabled as it was: Ensure sets `enabled` and
|
|
// removes the two attributes. Deleted — only through `cleanup delete` — the client is removed.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Mark is the attribute marking a client as the mesh's own work.
|
|
const Mark = "mesh.provisioned"
|
|
|
|
// MarkRetired and MarkRetiredWhy mark a retired client: when and why (novox/hq ADR 0230).
|
|
const (
|
|
MarkRetired = "mesh.retired"
|
|
MarkRetiredWhy = "mesh.retired-why"
|
|
)
|
|
|
|
// RolesMapper is the mapper every mesh client carries: realm roles as a flat `roles` claim in the id
|
|
// token, the access token and userinfo — what a consumer maps its own roles from.
|
|
func RolesMapper() Rep {
|
|
return Rep{
|
|
"name": "realm roles",
|
|
"protocol": "openid-connect",
|
|
"protocolMapper": "oidc-usermodel-realm-role-mapper",
|
|
"config": map[string]any{
|
|
"claim.name": "roles",
|
|
"jsonType.label": "String",
|
|
"multivalued": "true",
|
|
"id.token.claim": "true",
|
|
"access.token.claim": "true",
|
|
"userinfo.token.claim": "true",
|
|
},
|
|
}
|
|
}
|
|
|
|
var realmPath = regexp.MustCompile(`/realms/([^/]+)/?$`)
|
|
|
|
// RealmOf is the realm named by an issuer URL — `https://id.example/realms/Novox` is realm `Novox`.
|
|
// The issuer is the one value an assignment sets, so the realm is read out of it rather than set a
|
|
// second time where the two could disagree.
|
|
func RealmOf(issuer string) (string, error) {
|
|
u, err := url.Parse(issuer)
|
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
|
return "", fmt.Errorf("the issuer %q is not a URL", issuer)
|
|
}
|
|
m := realmPath.FindStringSubmatch(u.EscapedPath())
|
|
if m == nil {
|
|
return "", fmt.Errorf("the issuer %q does not end in /realms/<realm>", issuer)
|
|
}
|
|
realm, err := url.PathUnescape(m[1])
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return realm, nil
|
|
}
|
|
|
|
// RedirectsOf is the redirect URIs a consumer's contribution asks for: its callback under each name
|
|
// the mesh composed for its endpoint. Refused when there is nothing to register.
|
|
func RedirectsOf(values map[string]any) (root string, redirects []string, err error) {
|
|
callback, _ := values["callback"].(string)
|
|
if !strings.HasPrefix(callback, "/") {
|
|
raw, _ := json.Marshal(values["callback"])
|
|
return "", nil, fmt.Errorf("contributes no callback path (`callback`, starting with \"/\"): %s", raw)
|
|
}
|
|
var names []string
|
|
for _, key := range []string{"name", "internal-name"} {
|
|
n, _ := values[key].(string)
|
|
n = strings.TrimSpace(n)
|
|
if n != "" && !contains(names, n) {
|
|
names = append(names, n)
|
|
}
|
|
}
|
|
if len(names) == 0 {
|
|
return "", nil, errors.New("has no name the mesh composed (`name` / `internal-name`) — contribute a `label` and the `endpoint` it is reached on")
|
|
}
|
|
for _, n := range names {
|
|
redirects = append(redirects, "https://"+n+callback)
|
|
}
|
|
return "https://" + names[0], redirects, nil
|
|
}
|
|
|
|
func contains(list []string, s string) bool {
|
|
for _, x := range list {
|
|
if x == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// wanted is the fields the mesh owns on a client it made. Everything else is left as found.
|
|
func wanted(p Provision) (Rep, []string, error) {
|
|
root, redirects, err := RedirectsOf(p.Values)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
whose := "a consumer"
|
|
if p.Consumer != "" {
|
|
whose = "a module on " + p.Consumer
|
|
}
|
|
uris := make([]any, len(redirects))
|
|
for i, r := range redirects {
|
|
uris[i] = r
|
|
}
|
|
return Rep{
|
|
"clientId": p.As,
|
|
"name": p.As,
|
|
"description": "made by the mesh for " + whose + " — do not edit; it is reset",
|
|
"enabled": true,
|
|
"protocol": "openid-connect",
|
|
"publicClient": false,
|
|
"clientAuthenticatorType": "client-secret",
|
|
"secret": p.Password,
|
|
"rootUrl": root,
|
|
"baseUrl": root,
|
|
"redirectUris": uris,
|
|
"standardFlowEnabled": true,
|
|
"implicitFlowEnabled": false,
|
|
"directAccessGrantsEnabled": false,
|
|
"serviceAccountsEnabled": false,
|
|
}, redirects, nil
|
|
}
|
|
|
|
func stringList(v any) []string {
|
|
list, _ := v.([]any)
|
|
out := make([]string, 0, len(list))
|
|
for _, x := range list {
|
|
if s, ok := x.(string); ok {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func sameSet(a, b []string) bool {
|
|
x, y := append([]string(nil), a...), append([]string(nil), b...)
|
|
sort.Strings(x)
|
|
sort.Strings(y)
|
|
if len(x) != len(y) {
|
|
return false
|
|
}
|
|
for i := range x {
|
|
if x[i] != y[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func attributes(c Rep) map[string]any {
|
|
if a, ok := c["attributes"].(map[string]any); ok {
|
|
return a
|
|
}
|
|
return map[string]any{}
|
|
}
|
|
|
|
func marked(c Rep) bool { return attributes(c)[Mark] == "true" }
|
|
|
|
// OidcClients is the provision's meaning in one realm.
|
|
type OidcClients struct {
|
|
KC *Client
|
|
Realm string
|
|
}
|
|
|
|
// Ensure creates the consumer's client, or brings the mesh's existing one back to what the grant
|
|
// says. Answers "created" or "updated". Idempotent.
|
|
func (o OidcClients) Ensure(ctx context.Context, p Provision) (string, error) {
|
|
want, _, err := wanted(p)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
found, err := o.KC.FindClient(ctx, o.Realm, p.As)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if found != nil && !marked(found) {
|
|
return "", fmt.Errorf("realm %s already has a client %s the mesh did not make — left alone; "+
|
|
"delete or rename it if the mesh should own that id", o.Realm, p.As)
|
|
}
|
|
if found == nil {
|
|
want["attributes"] = map[string]any{Mark: "true"}
|
|
want["protocolMappers"] = []any{RolesMapper()}
|
|
if err := o.KC.CreateClient(ctx, o.Realm, want); err != nil {
|
|
return "", err
|
|
}
|
|
return "created", nil
|
|
}
|
|
// Overlay what the mesh owns on what is there, so a field Keycloak added or an operator set on a
|
|
// field the mesh does not own survives the update.
|
|
merged := Rep{}
|
|
for k, v := range found {
|
|
merged[k] = v
|
|
}
|
|
for k, v := range want {
|
|
merged[k] = v
|
|
}
|
|
attrs := map[string]any{}
|
|
for k, v := range attributes(found) {
|
|
attrs[k] = v
|
|
}
|
|
attrs[Mark] = "true"
|
|
// Asked for again: no longer marked to delete (novox/hq ADR 0230).
|
|
delete(attrs, MarkRetired)
|
|
delete(attrs, MarkRetiredWhy)
|
|
merged["attributes"] = attrs
|
|
id, _ := found["id"].(string)
|
|
if err := o.KC.UpdateClient(ctx, o.Realm, id, merged); err != nil {
|
|
return "", err
|
|
}
|
|
return "updated", o.ensureMapper(ctx, id)
|
|
}
|
|
|
|
func (o OidcClients) ensureMapper(ctx context.Context, id string) error {
|
|
want := RolesMapper()
|
|
mappers, err := o.KC.ListClientMappers(ctx, o.Realm, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var have Rep
|
|
for _, m := range mappers {
|
|
if m["name"] == want["name"] {
|
|
have = m
|
|
break
|
|
}
|
|
}
|
|
if have == nil {
|
|
return o.KC.AddClientMapper(ctx, o.Realm, id, want)
|
|
}
|
|
drifted := have["protocolMapper"] != want["protocolMapper"]
|
|
hc, _ := have["config"].(map[string]any)
|
|
for k, v := range want["config"].(map[string]any) {
|
|
if hc[k] != v {
|
|
drifted = true
|
|
}
|
|
}
|
|
if drifted {
|
|
want["id"] = have["id"]
|
|
return o.KC.UpdateClientMapper(ctx, o.Realm, id, want)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Holds says whether Keycloak still holds this consumer's client exactly as the grant says: present,
|
|
// the mesh's, enabled, confidential, with the mesh's secret and the redirects asked for. Reads only.
|
|
func (o OidcClients) Holds(ctx context.Context, p Provision) (bool, error) {
|
|
_, redirects, err := wanted(p)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
found, err := o.KC.FindClient(ctx, o.Realm, p.As)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if found == nil || !marked(found) || found["enabled"] == false || found["publicClient"] == true {
|
|
return false, nil
|
|
}
|
|
if !sameSet(stringList(found["redirectUris"]), redirects) {
|
|
return false, nil
|
|
}
|
|
id, _ := found["id"].(string)
|
|
mappers, err := o.KC.ListClientMappers(ctx, o.Realm, id)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
hasMapper := false
|
|
for _, m := range mappers {
|
|
if m["name"] == RolesMapper()["name"] {
|
|
hasMapper = true
|
|
}
|
|
}
|
|
if !hasMapper {
|
|
return false, nil
|
|
}
|
|
secret, err := o.KC.ClientSecretByID(ctx, o.Realm, id)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return secret == p.Password, nil
|
|
}
|
|
|
|
// Retire disables a consumer's client — only one the mesh made — and marks it with when and why.
|
|
// Answers what happened: "retired", "absent" or "not ours".
|
|
func (o OidcClients) Retire(ctx context.Context, as, why string, at time.Time) (string, error) {
|
|
found, err := o.KC.FindClient(ctx, o.Realm, as)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if found == nil {
|
|
return "absent", nil
|
|
}
|
|
if !marked(found) {
|
|
return "not ours", nil
|
|
}
|
|
merged := Rep{}
|
|
for k, v := range found {
|
|
merged[k] = v
|
|
}
|
|
attrs := map[string]any{}
|
|
for k, v := range attributes(found) {
|
|
attrs[k] = v
|
|
}
|
|
attrs[MarkRetired] = at.UTC().Format(time.RFC3339)
|
|
attrs[MarkRetiredWhy] = why
|
|
merged["attributes"] = attrs
|
|
merged["enabled"] = false
|
|
id, _ := found["id"].(string)
|
|
return "retired", o.KC.UpdateClient(ctx, o.Realm, id, merged)
|
|
}
|
|
|
|
// Inventory is every client in the realm the mesh made: enabled ones active, disabled ones retired —
|
|
// with when and why from the mark, or none for one disabled before the mark existed.
|
|
func (o OidcClients) Inventory(ctx context.Context) (Inventory, error) {
|
|
inv := Inventory{Active: []string{}, Retired: []Retired{}}
|
|
clients, err := o.KC.ListClients(ctx, o.Realm)
|
|
if err != nil {
|
|
return inv, err
|
|
}
|
|
for _, c := range clients {
|
|
if !marked(c) {
|
|
continue
|
|
}
|
|
id, _ := c["clientId"].(string)
|
|
a := attributes(c)
|
|
when, _ := a[MarkRetired].(string)
|
|
if c["enabled"] != false && when == "" {
|
|
inv.Active = append(inv.Active, id)
|
|
continue
|
|
}
|
|
at, _ := time.Parse(time.RFC3339, when)
|
|
why, _ := a[MarkRetiredWhy].(string)
|
|
inv.Retired = append(inv.Retired, Retired{Consumer: id, RetiredAt: at, Why: why, SizeBytes: -1, Kind: KindConsumer})
|
|
}
|
|
return inv, nil
|
|
}
|
|
|
|
// Delete removes a retired client — only one the mesh made, and only while it is disabled.
|
|
func (o OidcClients) Delete(ctx context.Context, as string) error {
|
|
found, err := o.KC.FindClient(ctx, o.Realm, as)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if found == nil {
|
|
return nil
|
|
}
|
|
if !marked(found) {
|
|
return fmt.Errorf("realm %s's client %s was not made by the mesh — left alone", o.Realm, as)
|
|
}
|
|
if found["enabled"] != false {
|
|
return fmt.Errorf("client %s is enabled — it is active, not retired, and is not deleted", as)
|
|
}
|
|
id, _ := found["id"].(string)
|
|
return o.KC.DeleteClientByID(ctx, o.Realm, id)
|
|
}
|