Skip to content

Commit b780345

Browse files
feat: add support for Authentik 2025 tenant endpoints
- Added compatibility with new Authentik 2025 tenant API endpoints while maintaining backward compatibility - Enhanced tenant identification with multiple ID field fallbacks (PK, TenantUUID, UUID, ID) - Improved tenant response parsing to handle multiple response formats (results, tenants arrays) - Added detailed debug logging for tenant endpoint discovery process - Refactored tenant selection logic into separate functions for better maintainability -
1 parent 27c9889 commit b780345

1 file changed

Lines changed: 146 additions & 27 deletions

File tree

pkg/hecate/authentik_email.go

Lines changed: 146 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,30 @@ type tenantEmailSettings struct {
3131
}
3232

3333
type tenantSummary struct {
34-
PK string `json:"pk"`
35-
Domain string `json:"domain"`
36-
Default bool `json:"default"`
34+
PK string `json:"pk"`
35+
TenantUUID string `json:"tenant_uuid"`
36+
UUID string `json:"uuid"`
37+
ID string `json:"id"`
38+
Domain string `json:"domain"`
39+
Default bool `json:"default"`
3740
}
3841

39-
type tenantListResponse struct {
40-
Results []tenantSummary `json:"results"`
42+
func (t *tenantSummary) identifier() string {
43+
if t == nil {
44+
return ""
45+
}
46+
switch {
47+
case strings.TrimSpace(t.PK) != "":
48+
return t.PK
49+
case strings.TrimSpace(t.TenantUUID) != "":
50+
return t.TenantUUID
51+
case strings.TrimSpace(t.UUID) != "":
52+
return t.UUID
53+
case strings.TrimSpace(t.ID) != "":
54+
return t.ID
55+
default:
56+
return ""
57+
}
4158
}
4259

4360
// ConfigureAuthentikEmail updates tenant-level SMTP settings using values from /opt/hecate/.env.
@@ -142,17 +159,16 @@ func ConfigureAuthentikEmail(rc *eos_io.RuntimeContext, cfg *AuthentikEmailConfi
142159

143160
client := authentik.NewUnifiedClient(baseURL, token)
144161

145-
tenant, err := resolveAuthentikTenant(rc, client)
162+
tenant, patchPath, err := resolveAuthentikTenant(rc, client)
146163
if err != nil {
147164
return fmt.Errorf("failed to resolve Authentik tenant: %w", err)
148165
}
149166

150167
logger.Info("Updating Authentik tenant",
151-
zap.String("tenant_pk", tenant.PK),
168+
zap.String("tenant_identifier", tenant.identifier()),
152169
zap.String("tenant_domain", tenant.Domain),
153-
zap.Bool("tenant_default", tenant.Default))
154-
155-
patchPath := fmt.Sprintf("/core/tenants/%s/", tenant.PK)
170+
zap.Bool("tenant_default", tenant.Default),
171+
zap.String("tenant_patch_endpoint", patchPath))
156172

157173
respBody, err := client.Patch(rc.Ctx, patchPath, payload)
158174
if err != nil {
@@ -177,34 +193,137 @@ func ConfigureAuthentikEmail(rc *eos_io.RuntimeContext, cfg *AuthentikEmailConfi
177193
return nil
178194
}
179195

180-
func resolveAuthentikTenant(rc *eos_io.RuntimeContext, client *authentik.UnifiedClient) (*tenantSummary, error) {
196+
type tenantEndpointCandidate struct {
197+
ListPath string
198+
BuildPatchPath func(string) string
199+
Description string
200+
}
201+
202+
func resolveAuthentikTenant(rc *eos_io.RuntimeContext, client *authentik.UnifiedClient) (*tenantSummary, string, error) {
181203
logger := otelzap.Ctx(rc.Ctx)
182204

183-
data, err := client.Get(rc.Ctx, "/core/tenants/")
184-
if err != nil {
185-
return nil, fmt.Errorf("failed to list Authentik tenants: %w", err)
205+
candidates := []tenantEndpointCandidate{
206+
{
207+
ListPath: "/core/tenants/",
208+
BuildPatchPath: func(id string) string {
209+
return fmt.Sprintf("/core/tenants/%s/", id)
210+
},
211+
Description: "Authentik ≤2024.12 core tenants endpoint",
212+
},
213+
{
214+
ListPath: "/tenants/tenants/",
215+
BuildPatchPath: func(id string) string {
216+
return fmt.Sprintf("/tenants/tenants/%s/", id)
217+
},
218+
Description: "Authentik 2025 tenants endpoint",
219+
},
220+
{
221+
ListPath: "/tenants/",
222+
BuildPatchPath: func(id string) string {
223+
return fmt.Sprintf("/tenants/%s/", id)
224+
},
225+
Description: "Authentik 2025 short tenants endpoint",
226+
},
186227
}
187228

188-
var tenants tenantListResponse
189-
if err := json.Unmarshal(data, &tenants); err != nil {
190-
return nil, fmt.Errorf("failed to decode Authentik tenant list: %w", err)
229+
var lastErr error
230+
231+
for _, candidate := range candidates {
232+
logger.Debug("Attempting Authentik tenant discovery",
233+
zap.String("endpoint", candidate.ListPath),
234+
zap.String("description", candidate.Description))
235+
236+
data, err := client.Get(rc.Ctx, candidate.ListPath)
237+
if err != nil {
238+
logger.Debug("Tenant endpoint request failed",
239+
zap.String("endpoint", candidate.ListPath),
240+
zap.Error(err))
241+
lastErr = err
242+
continue
243+
}
244+
245+
tenants, err := parseTenantListResponse(data)
246+
if err != nil {
247+
logger.Debug("Failed to parse tenant list response",
248+
zap.String("endpoint", candidate.ListPath),
249+
zap.Error(err))
250+
lastErr = err
251+
continue
252+
}
253+
254+
if len(tenants) == 0 {
255+
logger.Debug("Tenant endpoint returned no tenants",
256+
zap.String("endpoint", candidate.ListPath))
257+
lastErr = fmt.Errorf("no tenants returned from %s", candidate.ListPath)
258+
continue
259+
}
260+
261+
selected := selectTenant(tenants)
262+
id := selected.identifier()
263+
if id == "" {
264+
logger.Debug("Tenant missing identifier fields",
265+
zap.Any("tenant", selected))
266+
lastErr = fmt.Errorf("tenant has no identifier in %s", candidate.ListPath)
267+
continue
268+
}
269+
270+
patchPath := candidate.BuildPatchPath(id)
271+
logger.Debug("Tenant resolved",
272+
zap.String("endpoint", candidate.ListPath),
273+
zap.String("tenant_identifier", id),
274+
zap.String("patch_endpoint", patchPath))
275+
276+
return selected, patchPath, nil
277+
}
278+
279+
if lastErr != nil {
280+
return nil, "", fmt.Errorf("failed to list Authentik tenants: %w", lastErr)
191281
}
192282

193-
if len(tenants.Results) == 0 {
194-
return nil, fmt.Errorf("no tenants returned by Authentik API")
283+
return nil, "", fmt.Errorf("failed to list Authentik tenants: no endpoints succeeded")
284+
}
285+
286+
func parseTenantListResponse(data []byte) ([]*tenantSummary, error) {
287+
type tenantEnvelope struct {
288+
Results []*tenantSummary `json:"results"`
289+
Tenants []*tenantSummary `json:"tenants"`
290+
}
291+
292+
var envelope tenantEnvelope
293+
if err := json.Unmarshal(data, &envelope); err == nil {
294+
switch {
295+
case len(envelope.Results) > 0:
296+
return envelope.Results, nil
297+
case len(envelope.Tenants) > 0:
298+
return envelope.Tenants, nil
299+
}
300+
}
301+
302+
var arr []*tenantSummary
303+
if err := json.Unmarshal(data, &arr); err == nil && len(arr) > 0 {
304+
return arr, nil
305+
}
306+
307+
// If both attempts failed, return last error for context.
308+
if len(envelope.Results) == 0 && len(envelope.Tenants) == 0 && len(arr) == 0 {
309+
return nil, fmt.Errorf("unexpected tenant response format")
310+
}
311+
312+
return arr, nil
313+
}
314+
315+
func selectTenant(tenants []*tenantSummary) *tenantSummary {
316+
if len(tenants) == 0 {
317+
return nil
195318
}
196319

197-
// Prefer the default tenant if flagged as such.
198-
for _, tenant := range tenants.Results {
199-
if tenant.Default {
200-
return &tenant, nil
320+
for _, tenant := range tenants {
321+
if tenant != nil && tenant.Default {
322+
return tenant
201323
}
202324
}
203325

204-
// Fall back to the first tenant in the list.
205-
logger.Warn("No default tenant flagged; using first tenant from list",
206-
zap.Int("tenant_count", len(tenants.Results)))
207-
return &tenants.Results[0], nil
326+
return tenants[0]
208327
}
209328

210329
func maskSensitive(value string) string {

0 commit comments

Comments
 (0)