feat(iam): define a KeyRotation for every consumer except poimen-memory
Eight objects cover grafana, argocd, comfyui, minio, paperless, immich, forgejo and api-gw. Each names its ZITADEL app, the Secret it writes, the consumer to roll and whether the consumer reloads hot or needs a restart. All are suspended: the controller image does not exist yet, and an unsuspended object would claim an intent that no process acts on. The README records the retirement target for the 34 .enc.yaml files: six root-of-trust secrets that cannot rotate themselves survive, everything else becomes a KeyRotation and leaves git.
This commit was merged in pull request #84.
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
package zitadel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds everything the controller needs to call ZITADEL.
|
||||
type Config struct {
|
||||
// Issuer is the instance URL, for example https://zitadel.riotpiao.com.
|
||||
Issuer string
|
||||
// ClientID is the API app that this controller authenticates as.
|
||||
ClientID string
|
||||
// UserID is the machine user the assertion acts for. Empty means ClientID.
|
||||
UserID string
|
||||
// KeyID and PrivateKeyPEM identify the controller's own machine key.
|
||||
KeyID string
|
||||
PrivateKeyPEM []byte
|
||||
// HTTPClient is optional; a sane default is used when nil.
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// Client is a thin ZITADEL management client.
|
||||
type Client struct {
|
||||
cfg Config
|
||||
http *http.Client
|
||||
mu sync.Mutex
|
||||
token string
|
||||
until time.Time
|
||||
}
|
||||
|
||||
// New builds a client.
|
||||
func New(cfg Config) *Client {
|
||||
client := cfg.HTTPClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 20 * time.Second}
|
||||
}
|
||||
return &Client{cfg: cfg, http: client}
|
||||
}
|
||||
|
||||
// AccessToken returns a cached access token, minting one when needed.
|
||||
func (c *Client) AccessToken() (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.token != "" && time.Now().Before(c.until) {
|
||||
return c.token, nil
|
||||
}
|
||||
|
||||
assertion, err := SignAssertion(c.cfg.PrivateKeyPEM, c.cfg.KeyID, c.cfg.ClientID, c.cfg.UserID, c.cfg.Issuer, time.Now())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
|
||||
form.Set("assertion", assertion)
|
||||
form.Set("scope", "openid")
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(c.cfg.Issuer, "/")+"/oauth/v2/token", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return "", fmt.Errorf("token response did not parse: %w", err)
|
||||
}
|
||||
if parsed.AccessToken == "" {
|
||||
return "", fmt.Errorf("token response carried no access_token")
|
||||
}
|
||||
|
||||
lifetime := parsed.ExpiresIn
|
||||
if lifetime <= 0 {
|
||||
lifetime = 300
|
||||
}
|
||||
c.token = parsed.AccessToken
|
||||
// Refresh at 80 % of the lifetime so a slow call never presents an expired token.
|
||||
c.until = time.Now().Add(time.Duration(lifetime) * time.Second * 4 / 5)
|
||||
return c.token, nil
|
||||
}
|
||||
|
||||
// projectAppPath builds the management path for one OIDC app under a project.
|
||||
func (c *Client) projectAppPath(projectID, appID string) string {
|
||||
return fmt.Sprintf("/management/v1/projects/%s/apps/%s", projectID, appID)
|
||||
}
|
||||
|
||||
// MachineKey is one registered key for a service user.
|
||||
type MachineKey struct {
|
||||
KeyID string `json:"keyId"`
|
||||
Expiration string `json:"expirationDate,omitempty"`
|
||||
// Details holds the PEM bundle when the key was just created.
|
||||
Details string `json:"keyDetails,omitempty"`
|
||||
}
|
||||
|
||||
// NewMachineKeyJSON is the JSON document ZITADEL returns for a new JSON key.
|
||||
type NewMachineKeyJSON struct {
|
||||
Type string `json:"type"`
|
||||
KeyID string `json:"keyId"`
|
||||
Key string `json:"key"`
|
||||
UserID string `json:"userId"`
|
||||
ExpireAt string `json:"expirationDate,omitempty"`
|
||||
}
|
||||
|
||||
// CreateMachineKey adds a key to a service user and returns its material.
|
||||
//
|
||||
// ZITADEL keeps many keys per user, so this never invalidates the key that is
|
||||
// currently deployed. That is the whole reason the machine-key provider can
|
||||
// rotate with no gap.
|
||||
func (c *Client) CreateMachineKey(userID string) (*NewMachineKeyJSON, error) {
|
||||
payload := map[string]any{"type": "KEY_TYPE_JSON"}
|
||||
var out struct {
|
||||
KeyID string `json:"keyId"`
|
||||
KeyDetails string `json:"keyDetails"`
|
||||
}
|
||||
if err := c.post("/management/v1/users/"+userID+"/keys", payload, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.KeyDetails == "" {
|
||||
return nil, fmt.Errorf("create machine key returned no key material")
|
||||
}
|
||||
|
||||
raw, err := base64Decode(out.KeyDetails)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("key material is not base64: %w", err)
|
||||
}
|
||||
|
||||
var key NewMachineKeyJSON
|
||||
if err := json.Unmarshal(raw, &key); err != nil {
|
||||
return nil, fmt.Errorf("key material is not the expected JSON: %w", err)
|
||||
}
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
// DeleteMachineKey removes one key. Called only after the new key is proven.
|
||||
func (c *Client) DeleteMachineKey(userID, keyID string) error {
|
||||
return c.delete("/management/v1/users/" + userID + "/keys/" + keyID)
|
||||
}
|
||||
|
||||
// GenerateClientSecret replaces the client secret of an OIDC app.
|
||||
//
|
||||
// ZITADEL stores exactly one secret per app, so the previous secret stops working
|
||||
// at this call. The caller must publish the new value immediately.
|
||||
func (c *Client) GenerateClientSecret(projectID, appID string) (string, error) {
|
||||
var out struct {
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
}
|
||||
path := c.projectAppPath(projectID, appID) + "/oidc_config/_generate_client_secret"
|
||||
if err := c.post(path, map[string]any{}, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out.ClientSecret == "" {
|
||||
return "", fmt.Errorf("generate client secret returned an empty value")
|
||||
}
|
||||
return out.ClientSecret, nil
|
||||
}
|
||||
|
||||
// Introspect checks a client credential against the token endpoint.
|
||||
//
|
||||
// A successful call returns an object with `active`. The credentials are valid
|
||||
// when the endpoint answers 200, whatever `active` says, because `active`
|
||||
// describes the token rather than the client.
|
||||
func (c *Client) Introspect(clientID, clientSecret string) error {
|
||||
form := url.Values{}
|
||||
form.Set("token", "probe")
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(c.cfg.Issuer, "/")+"/oauth/v2/introspect", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.SetBasicAuth(clientID, clientSecret)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("introspection request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("introspection returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExchangeMachineKey obtains a token with a specific key, proving that key works.
|
||||
func (c *Client) ExchangeMachineKey(clientID, userID, keyID string, keyPEM []byte, audienceProject string) error {
|
||||
assertion, err := SignAssertion(keyPEM, keyID, clientID, userID, c.cfg.Issuer, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scope := "openid"
|
||||
if audienceProject != "" {
|
||||
scope += " urn:zitadel:iam:org:project:id:" + audienceProject + ":aud"
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
|
||||
form.Set("assertion", assertion)
|
||||
form.Set("scope", scope)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(c.cfg.Issuer, "/")+"/oauth/v2/token", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("key exchange failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("key exchange returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) do(method, path string, payload any, out any) error {
|
||||
token, err := c.AccessToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = bytes.NewReader(encoded)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, strings.TrimRight(c.cfg.Issuer, "/")+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("%s %s returned %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
if out != nil && len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return fmt.Errorf("%s %s response did not parse: %w", method, path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) post(path string, payload, out any) error {
|
||||
return c.do(http.MethodPost, path, payload, out)
|
||||
}
|
||||
|
||||
func (c *Client) delete(path string) error {
|
||||
return c.do(http.MethodDelete, path, nil, nil)
|
||||
}
|
||||
|
||||
func base64Decode(s string) ([]byte, error) {
|
||||
if raw, err := base64StdDecode(s); err == nil {
|
||||
return raw, nil
|
||||
}
|
||||
return base64URLDecode(s)
|
||||
}
|
||||
|
||||
func base64StdDecode(s string) ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
func base64URLDecode(s string) ([]byte, error) {
|
||||
return base64.RawURLEncoding.DecodeString(s)
|
||||
}
|
||||
Reference in New Issue
Block a user