Skip to content

Commit 503f4b1

Browse files
committed
add convenience endpoint, merge fix
1 parent 02886c7 commit 503f4b1

File tree

8 files changed

+165
-39
lines changed

8 files changed

+165
-39
lines changed

taco/internal/api/internal.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,18 @@ func RegisterInternalRoutes(e *echo.Echo, deps Dependencies) {
3636
internal := e.Group("/internal/api")
3737
internal.Use(middleware.WebhookAuth())
3838

39-
// Validate org UUID from webhook header and add to domain context
40-
internal.Use(middleware.WebhookOrgUUIDMiddleware())
41-
log.Println("Org UUID validation middleware enabled for internal routes")
39+
// Add org resolution middleware - resolves org UUID or external ID to UUID and adds to domain context
40+
if deps.QueryStore != nil {
41+
if db := repositories.GetDBFromQueryStore(deps.QueryStore); db != nil {
42+
// Create identifier resolver (infrastructure layer)
43+
identifierResolver := repositories.NewIdentifierResolver(db)
44+
// Pass interface to middleware (clean architecture!)
45+
internal.Use(middleware.ResolveOrgContextMiddleware(identifierResolver))
46+
log.Println("Org context resolution middleware enabled for internal routes (UUID and external org ID)")
47+
} else {
48+
log.Println("WARNING: QueryStore does not implement GetDB() *gorm.DB - org resolution disabled")
49+
}
50+
}
4251

4352
// Organization and User management endpoints
4453
if orgRepo != nil && userRepo != nil {
@@ -48,6 +57,7 @@ func RegisterInternalRoutes(e *echo.Echo, deps Dependencies) {
4857
// Organization endpoints
4958
internal.POST("/orgs", orgHandler.CreateOrganization)
5059
internal.POST("/orgs/sync", orgHandler.SyncExternalOrg)
60+
internal.GET("/orgs/user", orgHandler.GetMyOrganizations) // Get orgs for user - no org context required
5161
internal.GET("/orgs/:orgId", orgHandler.GetOrganization)
5262
internal.GET("/orgs", orgHandler.ListOrganizations)
5363

taco/internal/api/org_handler.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,71 @@ func (h *OrgHandler) GetOrganization(c echo.Context) error {
355355
})
356356
}
357357

358+
// GetMyOrganizations handles GET /internal/orgs/user
359+
// Returns organizations for the current user (no org context required)
360+
// Requires: X-User-ID and X-Email headers with webhook secret authentication
361+
func (h *OrgHandler) GetMyOrganizations(c echo.Context) error {
362+
ctx := c.Request().Context()
363+
364+
// Get user context from webhook middleware
365+
userID := c.Get("user_id")
366+
if userID == nil {
367+
return c.JSON(http.StatusBadRequest, map[string]string{
368+
"error": "user context required",
369+
})
370+
}
371+
372+
userIDStr, ok := userID.(string)
373+
if !ok || userIDStr == "" {
374+
return c.JSON(http.StatusInternalServerError, map[string]string{
375+
"error": "invalid user context",
376+
})
377+
}
378+
379+
// Get all organizations
380+
allOrgs, err := h.orgRepo.List(ctx)
381+
if err != nil {
382+
slog.Error("Failed to list organizations for user", "userID", userIDStr, "error", err)
383+
return c.JSON(http.StatusInternalServerError, map[string]string{
384+
"error": "failed to list organizations",
385+
})
386+
}
387+
388+
// Filter to orgs where user has roles (if RBAC is enabled)
389+
var userOrgs []*domain.Organization
390+
if h.rbacManager != nil {
391+
for _, org := range allOrgs {
392+
// Check if user has any roles in this org
393+
orgCtx := domain.ContextWithOrg(ctx, org.ID)
394+
hasAccess, _ := h.rbacManager.IsEnabled(orgCtx)
395+
if hasAccess {
396+
userOrgs = append(userOrgs, org)
397+
}
398+
}
399+
} else {
400+
// No RBAC - return all orgs
401+
userOrgs = allOrgs
402+
}
403+
404+
// Convert to response format
405+
response := make([]CreateOrgResponse, len(userOrgs))
406+
for i, org := range userOrgs {
407+
response[i] = CreateOrgResponse{
408+
ID: org.ID,
409+
Name: org.Name,
410+
DisplayName: org.DisplayName,
411+
ExternalOrgID: org.ExternalOrgID,
412+
CreatedBy: org.CreatedBy,
413+
CreatedAt: org.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
414+
}
415+
}
416+
417+
return c.JSON(http.StatusOK, map[string]interface{}{
418+
"organizations": response,
419+
"count": len(response),
420+
})
421+
}
422+
358423
// ListOrganizations handles GET /internal/orgs
359424
func (h *OrgHandler) ListOrganizations(c echo.Context) error {
360425
ctx := c.Request().Context()

taco/internal/auth/terraform.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -822,7 +822,9 @@ func (h *Handler) ensureUserHasOrg(ctx context.Context, subject, email string) (
822822
orgName := fmt.Sprintf("user-%s", subject[:min(8, len(subject))])
823823
orgDisplayName := fmt.Sprintf("%s's Organization", email)
824824

825-
org, err := h.orgRepo.Create(ctx, orgName, orgDisplayName, subject)
825+
// Create org with orgID=orgName (the unique identifier)
826+
// name=orgName (stored in DB), displayName (friendly name), no externalOrgID, createdBy=subject
827+
org, err := h.orgRepo.Create(ctx, orgName, orgName, orgDisplayName, "", subject)
826828
if err != nil {
827829
return "", fmt.Errorf("failed to create org: %w", err)
828830
}

taco/internal/domain/resource_resolver.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ var (
1212
ErrAmbiguousIdentifier = errors.New("identifier matches multiple resources")
1313

1414
uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
15-
absoluteNamePattern = regexp.MustCompile(`^org-([a-zA-Z0-9_-]+)/(.+)$`)
15+
// Absolute name pattern: supports UUID, external org ID, or legacy name format
16+
// Examples: "550e8400-e29b-41d4-a716-446655440000/unit-name", "auth0_org_123/unit-name", "org-acme/unit-name" (legacy)
17+
absoluteNamePattern = regexp.MustCompile(`^([a-zA-Z0-9_-]+)/(.+)$`)
18+
legacyOrgPattern = regexp.MustCompile(`^org-([a-zA-Z0-9_-]+)$`)
1619
)
1720

1821
type IdentifierType int
@@ -34,7 +37,9 @@ type ParsedIdentifier struct {
3437
// Supports three formats:
3538
// - UUID: "a1b2c3d4-1234-5678-90ab-cdef12345678"
3639
// - Simple name: "dev" (resolved within current org context)
37-
// - Absolute name: "org-acme/dev" (explicitly specifies org)
40+
// - Absolute name: "<org-uuid-or-external-id>/dev" (explicitly specifies org)
41+
// Examples: "550e8400-e29b-41d4-a716-446655440000/dev", "auth0_org_123/dev"
42+
// Legacy format "org-acme/dev" is also supported but deprecated (names are not unique)
3843
func ParseIdentifier(identifier string) (*ParsedIdentifier, error) {
3944
if identifier == "" {
4045
return nil, fmt.Errorf("%w: empty identifier", ErrInvalidIdentifier)
@@ -48,10 +53,18 @@ func ParseIdentifier(identifier string) (*ParsedIdentifier, error) {
4853
}
4954

5055
if matches := absoluteNamePattern.FindStringSubmatch(identifier); matches != nil {
56+
orgIdentifier := matches[1]
57+
resourceName := matches[2]
58+
59+
// Strip "org-" prefix if present (legacy format)
60+
if legacyMatches := legacyOrgPattern.FindStringSubmatch(orgIdentifier); legacyMatches != nil {
61+
orgIdentifier = legacyMatches[1]
62+
}
63+
5164
return &ParsedIdentifier{
5265
Type: IdentifierTypeAbsoluteName,
53-
OrgName: matches[1],
54-
Name: matches[2],
66+
OrgName: orgIdentifier, // This is now UUID, external ID, or legacy name
67+
Name: resourceName,
5568
}, nil
5669
}
5770

taco/internal/middleware/org_context.go

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,24 +27,47 @@ func JWTOrgUUIDMiddleware() echo.MiddlewareFunc {
2727
}
2828
}
2929

30-
// WebhookOrgUUIDMiddleware extracts org UUID from webhook header and adds to domain context
31-
// For internal routes (/internal/api/*) - expects UUID in X-Org-ID header
32-
func WebhookOrgUUIDMiddleware() echo.MiddlewareFunc {
30+
// ResolveOrgContextMiddleware resolves org identifier to UUID and adds to domain context
31+
// For internal routes (/internal/api/*) - resolves X-Org-ID header (UUID or external org ID)
32+
// Skips validation for endpoints that don't require an existing org (like creating/listing orgs)
33+
func ResolveOrgContextMiddleware(resolver domain.IdentifierResolver) echo.MiddlewareFunc {
3334
return func(next echo.HandlerFunc) echo.HandlerFunc {
3435
return func(c echo.Context) error {
35-
orgUUID, ok := c.Get("organization_id").(string)
36-
if !ok || orgUUID == "" {
37-
log.Printf("[WebhookOrgUUID] organization_id not found in context")
36+
// Skip org resolution for endpoints that create/list orgs
37+
path := c.Request().URL.Path
38+
method := c.Request().Method
39+
40+
// These endpoints don't require an existing org context
41+
skipOrgResolution := (method == "POST" && path == "/internal/api/orgs") ||
42+
(method == "POST" && path == "/internal/api/orgs/sync") ||
43+
(method == "GET" && path == "/internal/api/orgs") ||
44+
(method == "GET" && path == "/internal/api/orgs/user")
45+
46+
if skipOrgResolution {
47+
log.Printf("[ResolveOrgContext] Skipping org resolution for %s %s", method, path)
48+
return next(c)
49+
}
50+
51+
// Get org identifier from webhook auth middleware
52+
orgIdentifier, ok := c.Get("organization_id").(string)
53+
if !ok || orgIdentifier == "" {
54+
log.Printf("[ResolveOrgContext] organization_id not found in context")
3855
return echo.NewHTTPError(400, "X-Org-ID header is required")
3956
}
4057

41-
if !domain.IsUUID(orgUUID) {
42-
log.Printf("[WebhookOrgUUID] organization_id is not a UUID: %s", orgUUID)
43-
return echo.NewHTTPError(400, "X-Org-ID must be a UUID")
58+
// Resolve identifier to UUID (accepts UUID or external org ID, NOT names)
59+
orgUUID, err := resolver.ResolveOrganization(c.Request().Context(), orgIdentifier)
60+
if err != nil {
61+
log.Printf("[ResolveOrgContext] Failed to resolve org identifier %q: %v", orgIdentifier, err)
62+
return echo.NewHTTPError(400, map[string]string{
63+
"error": "Invalid organization identifier",
64+
"details": err.Error(),
65+
})
4466
}
4567

46-
log.Printf("[WebhookOrgUUID] Found org UUID: %s", orgUUID)
68+
log.Printf("[ResolveOrgContext] Resolved %q to UUID: %s", orgIdentifier, orgUUID)
4769

70+
// Add org UUID to domain context
4871
ctx := domain.ContextWithOrg(c.Request().Context(), orgUUID)
4972
c.SetRequest(c.Request().WithContext(ctx))
5073

taco/internal/middleware/webhook.go

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -54,27 +54,32 @@ func WebhookAuth() echo.MiddlewareFunc {
5454
})
5555
}
5656

57-
userID := c.Request().Header.Get("X-User-ID")
58-
email := c.Request().Header.Get("X-Email")
59-
orgID := c.Request().Header.Get("X-Org-ID")
57+
userID := c.Request().Header.Get("X-User-ID")
58+
email := c.Request().Header.Get("X-Email")
59+
orgID := c.Request().Header.Get("X-Org-ID")
6060

61-
// Skip org validation for create org endpoint
62-
isCreateOrg := c.Request().Method == http.MethodPost && c.Path() == "/internal/api/orgs"
61+
// Skip org validation for endpoints that don't require existing org
62+
path := c.Request().URL.Path
63+
method := c.Request().Method
64+
skipOrgHeader := (method == http.MethodPost && path == "/internal/api/orgs") ||
65+
(method == http.MethodPost && path == "/internal/api/orgs/sync") ||
66+
(method == http.MethodGet && path == "/internal/api/orgs") ||
67+
(method == http.MethodGet && path == "/internal/api/orgs/user")
6368

64-
if userID == "" {
69+
if userID == "" {
70+
return c.JSON(http.StatusBadRequest, map[string]string{
71+
"error": "X-User-ID header required",
72+
})
73+
}
74+
75+
// Require org ID for all requests except org creation/listing
76+
if !skipOrgHeader {
77+
if orgID == "" {
6578
return c.JSON(http.StatusBadRequest, map[string]string{
66-
"error": "X-User-ID header required",
79+
"error": "X-Org-ID header required",
6780
})
6881
}
69-
70-
// Require org UUID for all requests except create org
71-
if !isCreateOrg {
72-
if orgID == "" {
73-
return c.JSON(http.StatusBadRequest, map[string]string{
74-
"error": "X-Org-ID header required",
75-
})
76-
}
77-
}
82+
}
7883

7984
principal := rbac.Principal{
8085
Subject: userID,

taco/internal/repositories/identifier_resolver.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package repositories
33
import (
44
"context"
55
"fmt"
6-
"strings"
76

87
"github.com/diggerhq/digger/opentaco/internal/domain"
98
"gorm.io/gorm"
@@ -39,9 +38,9 @@ func (r *gormIdentifierResolver) ResolveOrganization(ctx context.Context, identi
3938
return parsed.UUID, nil
4039
}
4140

42-
43-
// If not found by name, try external org ID
44-
// This handles cases where someone passes an external ID directly
41+
// Try to resolve by external org ID
42+
// Names are NOT unique, so we only support UUID or external org ID
43+
var result struct{ ID string }
4544
err = r.db.WithContext(ctx).
4645
Table("organizations").
4746
Select("id").
@@ -52,7 +51,11 @@ func (r *gormIdentifierResolver) ResolveOrganization(ctx context.Context, identi
5251
return result.ID, nil
5352
}
5453

55-
return "", fmt.Errorf("organization not found: %s", parsed.Name)
54+
if err == gorm.ErrRecordNotFound {
55+
return "", fmt.Errorf("organization not found with external ID: %s (names are not unique and cannot be resolved)", parsed.Name)
56+
}
57+
58+
return "", fmt.Errorf("failed to resolve organization: %w", err)
5659
}
5760

5861
// ResolveUnit resolves unit identifier to UUID within an organization

taco/internal/repositories/org_repository.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"log/slog"
8+
"strings"
89
"time"
910

1011
"github.com/diggerhq/digger/opentaco/internal/domain"
@@ -19,6 +20,10 @@ type orgRepository struct {
1920
db *gorm.DB
2021
}
2122

23+
const (
24+
queryOrgByName = "name = ?"
25+
)
26+
2227
// Helper function to safely get string value from pointer
2328
func getStringValue(ptr *string) string {
2429
if ptr == nil {

0 commit comments

Comments
 (0)