From ba3f3a242426fec1a703508423409757d9e5fe24 Mon Sep 17 00:00:00 2001 From: Brian Reardon Date: Thu, 23 Oct 2025 17:23:08 -0700 Subject: [PATCH 1/2] refactor name uniqueness, cli resolves name --- taco/cmd/statesman/main.go | 35 +----- taco/cmd/taco/commands/rbac.go | 110 ++++++++++++++---- taco/cmd/taco/commands/unit.go | 47 ++++++-- taco/internal/api/internal.go | 15 +-- taco/internal/api/routes.go | 29 +++-- taco/internal/auth/handler.go | 36 +++++- taco/internal/auth/jwt.go | 8 +- taco/internal/auth/terraform.go | 97 +++++++++++++-- taco/internal/domain/identifier_resolver.go | 14 +-- taco/internal/domain/organization.go | 24 ++-- taco/internal/middleware/auth.go | 50 ++++---- taco/internal/middleware/org_context.go | 76 +++++------- taco/internal/middleware/webhook.go | 52 +++------ taco/internal/observability/sync_health.go | 4 +- taco/internal/query/common/sql_store.go | 41 +++---- taco/internal/query/types/models.go | 9 +- taco/internal/rbac/querystore.go | 8 +- taco/internal/rbac/rbac.go | 4 + taco/internal/repositories/default_org.go | 58 --------- .../repositories/identifier_resolver.go | 63 ++++++---- taco/internal/repositories/org_repository.go | 71 +++++------ taco/internal/repositories/user_repository.go | 9 +- taco/internal/tfe/workspaces.go | 9 +- taco/pkg/sdk/client.go | 1 + 24 files changed, 474 insertions(+), 396 deletions(-) delete mode 100644 taco/internal/repositories/default_org.go diff --git a/taco/cmd/statesman/main.go b/taco/cmd/statesman/main.go index 732ecad05..d6d531c15 100644 --- a/taco/cmd/statesman/main.go +++ b/taco/cmd/statesman/main.go @@ -109,55 +109,32 @@ func main() { log.Printf("Query backend already has %d units, skipping sync", len(existingUnits)) } - // create repository - // repository coordinates blob storage with query index internally - // Get the underlying *gorm.DB from the query store db := repositories.GetDBFromQueryStore(queryStore) if db == nil { log.Fatalf("Query store does not provide GetDB method") } - // Ensure default organization exists - defaultOrgUUID, err := repositories.EnsureDefaultOrganization(context.Background(), db) - if err != nil { - log.Fatalf("Failed to ensure default organization: %v", err) - } - log.Printf("Default organization ensured: %s", defaultOrgUUID) - repo := repositories.NewUnitRepository(db, blobStore) log.Println("Repository initialized (database-first with blob storage backend)") - // Create RBAC Manager rbacManager, err := rbac.NewRBACManagerFromQueryStore(queryStore) if err != nil { log.Fatalf("Failed to create RBAC manager: %v", err) } - // --- Create Domain Interfaces with Optional Authorization --- - // These interfaces are what handlers will use var fullRepo domain.UnitRepository = repo - // Wrap with authorization if auth is enabled if !*authDisable { log.Println("Authorization is ENABLED. Wrapping repository with RBAC.") - - // Create bootstrap context with default org for RBAC check - // During startup, we need org context to check RBAC status - bootstrapCtx := domain.ContextWithOrg(context.Background(), defaultOrgUUID) - - // Verify RBAC manager was created successfully (fail closed for security) - canInit, err := rbacManager.IsEnabled(bootstrapCtx) - if err != nil { - log.Fatalf("Failed to verify RBAC manager: %v", err) - } - - if !canInit { - log.Println("RBAC is NOT initialized. System will operate in permissive mode until RBAC is initialized via /v1/rbac/init") - } - fullRepo = repositories.NewAuthorizingRepository(repo, rbacManager) } else { log.Println("Authorization is DISABLED via flag. All operations allowed.") + + // Ensure system org exists for auth-disabled mode + if err := repositories.EnsureSystemOrganization(context.Background(), db, domain.SystemOrgUUID); err != nil { + log.Fatalf("Failed to create system organization: %v", err) + } + log.Printf("System organization ensured: %s (name: system)", domain.SystemOrgUUID) } // Initialize analytics with system ID management (always create system ID) diff --git a/taco/cmd/taco/commands/rbac.go b/taco/cmd/taco/commands/rbac.go index 35dc98f08..ec4ace5d3 100644 --- a/taco/cmd/taco/commands/rbac.go +++ b/taco/cmd/taco/commands/rbac.go @@ -176,7 +176,7 @@ func init() { // rbac user assign command var rbacUserAssignCmd = &cobra.Command{ - Use: "assign ", + Use: "assign ", Short: "Assign a role to a user", Long: `Assign a role to a user by email address. The user must have logged in at least once to be found in the system.`, Args: cobra.ExactArgs(2), @@ -184,7 +184,7 @@ var rbacUserAssignCmd = &cobra.Command{ client := newAuthedClient() email := args[0] - roleID := args[1] + roleID := mustResolveRoleID(context.Background(), client, args[1]) printVerbose("Assigning role %s to user %s", roleID, email) @@ -209,7 +209,7 @@ var rbacUserAssignCmd = &cobra.Command{ // rbac user revoke command var rbacUserRevokeCmd = &cobra.Command{ - Use: "revoke ", + Use: "revoke ", Short: "Revoke a role from a user", Long: `Revoke a role from a user by email address.`, Args: cobra.ExactArgs(2), @@ -217,7 +217,7 @@ var rbacUserRevokeCmd = &cobra.Command{ client := newAuthedClient() email := args[0] - roleID := args[1] + roleID := mustResolveRoleID(context.Background(), client, args[1]) printVerbose("Revoking role %s from user %s", roleID, email) @@ -382,13 +382,14 @@ var rbacRoleListCmd = &cobra.Command{ // Create tabwriter w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "ID\tNAME\tDESCRIPTION\tPERMISSIONS\tCREATED") + fmt.Fprintln(w, "NAME\tDESCRIPTION\tPERMISSIONS\tCREATED") for _, role := range roles { permissions := strings.Join(role.Permissions, ", ") - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", - role.ID, - role.Name, + name := role.Name + if name == "" { name = role.ID } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + name, role.Description, permissions, role.CreatedAt, @@ -404,14 +405,14 @@ var rbacRoleListCmd = &cobra.Command{ // rbac role delete command var rbacRoleDeleteCmd = &cobra.Command{ - Use: "delete ", + Use: "delete ", Short: "Delete a role", - Long: `Delete a role by ID.`, + Long: `Delete a role by name.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client := newAuthedClient() - roleID := args[0] + roleID := mustResolveRoleID(context.Background(), client, args[0]) printVerbose("Deleting role %s", roleID) @@ -597,7 +598,7 @@ var rbacPermissionListCmd = &cobra.Command{ } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "ID\tName\tDescription\tRules\tCreated") + fmt.Fprintln(w, "NAME\tDESCRIPTION\tRULES\tCREATED") for _, permission := range permissions { rules := "" @@ -608,9 +609,10 @@ var rbacPermissionListCmd = &cobra.Command{ rules += fmt.Sprintf("%s:%s:%s", rule.Effect, strings.Join(rule.Actions, ","), strings.Join(rule.Resources, ",")) } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", - permission.ID, - permission.Name, + name := permission.Name + if name == "" { name = permission.ID } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + name, permission.Description, rules, permission.CreatedAt, @@ -625,11 +627,11 @@ var rbacPermissionListCmd = &cobra.Command{ // rbac permission delete command var rbacPermissionDeleteCmd = &cobra.Command{ - Use: "delete ", + Use: "delete ", Short: "Delete a permission", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - id := args[0] + id := mustResolvePermissionID(context.Background(), newAuthedClient(), args[0]) client := newAuthedClient() @@ -895,15 +897,14 @@ func testUserListOutput(client *sdk.Client, email string, args []string) (*TestR // rbac role assign-policy command var rbacRoleAssignPolicyCmd = &cobra.Command{ - Use: "assign-policy ", + Use: "assign-policy ", Short: "Assign a policy to a role", Long: `Assign a policy to a role, giving the role the permissions defined in the policy.`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - roleID := args[0] - permissionID := args[1] - client := newAuthedClient() + roleID := mustResolveRoleID(context.Background(), client, args[0]) + permissionID := mustResolvePermissionID(context.Background(), client, args[1]) req := map[string]string{ "role_id": roleID, @@ -926,15 +927,14 @@ var rbacRoleAssignPolicyCmd = &cobra.Command{ // rbac role revoke-permission command var rbacRoleRevokePermissionCmd = &cobra.Command{ - Use: "revoke-permission ", + Use: "revoke-permission ", Short: "Revoke a permission from a role", Long: `Revoke a permission from a role, removing the access rights defined in the permission.`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - roleID := args[0] - permissionID := args[1] - client := newAuthedClient() + roleID := mustResolveRoleID(context.Background(), client, args[0]) + permissionID := mustResolvePermissionID(context.Background(), client, args[1]) resp, err := client.Delete(context.Background(), "/v1/rbac/roles/"+roleID+"/permissions/"+permissionID) if err != nil { @@ -949,3 +949,63 @@ var rbacRoleRevokePermissionCmd = &cobra.Command{ return nil }, } + +// mustResolveRoleID resolves a role name to its ID +// If the argument is already a valid identifier, it's returned as-is +func mustResolveRoleID(ctx context.Context, client *sdk.Client, arg string) string { + resp, err := client.Get(ctx, "/v1/rbac/roles") + if err != nil || resp.StatusCode != 200 { + return arg // fallback + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return arg + } + + var roles []Role + if err := json.Unmarshal(body, &roles); err != nil { + return arg + } + + for _, r := range roles { + if r.Name == arg || r.ID == arg { + if r.ID != "" { + return r.ID + } + return arg + } + } + return arg +} + +// mustResolvePermissionID resolves a permission name to its ID +// If the argument is already a valid identifier, it's returned as-is +func mustResolvePermissionID(ctx context.Context, client *sdk.Client, arg string) string { + resp, err := client.Get(ctx, "/v1/rbac/permissions") + if err != nil || resp.StatusCode != 200 { + return arg // fallback + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return arg + } + + var permissions []Permission + if err := json.Unmarshal(body, &permissions); err != nil { + return arg + } + + for _, p := range permissions { + if p.Name == arg || p.ID == arg { + if p.ID != "" { + return p.ID + } + return arg + } + } + return arg +} diff --git a/taco/cmd/taco/commands/unit.go b/taco/cmd/taco/commands/unit.go index b0995d200..fb88a8a06 100644 --- a/taco/cmd/taco/commands/unit.go +++ b/taco/cmd/taco/commands/unit.go @@ -46,6 +46,31 @@ func init() { unitCmd.AddCommand(unitStatusCmd) } +// mustResolveUnitID resolves a unit name to its ID within the current org using the API. +// If the argument already looks like an ID (UUID or contains '/'), it is returned as-is. +func mustResolveUnitID(ctx context.Context, client *sdk.Client, arg string) string { + // Pass through hierarchical names like prefix/name + if strings.Contains(arg, "/") { + return arg + } + // Fast path: if this looks like a UUID, treat as ID + if _, err := uuid.Parse(arg); err == nil { + return arg + } + // Resolve by listing with prefix and exact match on Name + resp, err := client.ListUnits(ctx, "") + if err != nil || len(resp.Units) == 0 { + return arg // fallback to original arg + } + for _, u := range resp.Units { + if u.Name == arg || u.ID == arg { + if u.ID != "" { return u.ID } + return arg + } + } + return arg +} + var unitCreateCmd = &cobra.Command{ Use: "create ", Short: "Create a new unit", @@ -173,11 +198,13 @@ var unitListCmd = &cobra.Command{ } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "ID\tSIZE\tUPDATED\tLOCKED") + fmt.Fprintln(w, "NAME\tSIZE\tUPDATED\tLOCKED") for _, u := range filtered { locked := "" if u.Locked { locked = "yes" } - fmt.Fprintf(w, "%s\t%d\t%s\t%s\n", u.ID, u.Size, u.Updated.Format("2006-01-02 15:04:05"), locked) + name := u.Name + if name == "" { name = u.ID } + fmt.Fprintf(w, "%s\t%d\t%s\t%s\n", name, u.Size, u.Updated.Format("2006-01-02 15:04:05"), locked) } w.Flush() fmt.Printf("\nTotal: %d units (showing %d with read access)\n", resp.Count, len(filtered)) @@ -192,7 +219,7 @@ var unitInfoCmd = &cobra.Command{ Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client := newAuthedClient() - unitID := args[0] + unitID := mustResolveUnitID(context.Background(), client, args[0]) printVerbose("Getting unit metadata: %s", unitID) unit, err := client.GetUnit(context.Background(), unitID) if err != nil { return fmt.Errorf("failed to get unit info: %w", err) } @@ -209,7 +236,7 @@ var unitDeleteCmd = &cobra.Command{ Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client := newAuthedClient() - unitID := args[0] + unitID := mustResolveUnitID(context.Background(), client, args[0]) printVerbose("Deleting unit: %s", unitID) if err := client.DeleteUnit(context.Background(), unitID); err != nil { return fmt.Errorf("failed to delete unit: %w", err) @@ -227,7 +254,7 @@ var unitPullCmd = &cobra.Command{ analytics.SendEssential("taco_unit_pull_started") client := newAuthedClient() - unitID := args[0] + unitID := mustResolveUnitID(context.Background(), client, args[0]) printVerbose("Downloading unit: %s", unitID) data, err := client.DownloadUnit(context.Background(), unitID) if err != nil { @@ -257,7 +284,7 @@ var unitPushCmd = &cobra.Command{ analytics.SendEssential("taco_unit_push_started") client := newAuthedClient() - unitID := args[0] + unitID := mustResolveUnitID(context.Background(), client, args[0]) inputFile := args[1] printVerbose("Uploading unit: %s from %s", unitID, inputFile) data, err := os.ReadFile(inputFile) @@ -282,7 +309,7 @@ var unitLockCmd = &cobra.Command{ Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { client := newAuthedClient() - unitID := args[0] + unitID := mustResolveUnitID(context.Background(), client, args[0]) printVerbose("Locking unit: %s", unitID) lockInfo := &sdk.LockInfo{ID: uuid.New().String(), Who: fmt.Sprintf("taco@%s", getHostname()), Version: "1.0.0", Created: time.Now()} result, err := client.LockUnit(context.Background(), unitID, lockInfo) @@ -299,7 +326,7 @@ var unitUnlockCmd = &cobra.Command{ Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { client := newAuthedClient() - unitID := args[0] + unitID := mustResolveUnitID(context.Background(), client, args[0]) lockID := "" if len(args) > 1 { lockID = args[1] } else { lockID = getLockID(unitID); if lockID == "" { return fmt.Errorf("no lock ID provided and none found for %s", unitID) } } printVerbose("Unlocking unit: %s with lock ID: %s", unitID, lockID) @@ -316,7 +343,7 @@ var unitAcquireCmd = &cobra.Command{ Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { client := sdk.NewClient(serverURL) - unitID := args[0] + unitID := mustResolveUnitID(context.Background(), client, args[0]) printVerbose("Acquiring unit: %s", unitID) lockInfo := &sdk.LockInfo{ID: uuid.New().String(), Who: fmt.Sprintf("taco@%s", getHostname()), Version: "1.0.0", Created: time.Now()} result, err := client.LockUnit(context.Background(), unitID, lockInfo) @@ -346,7 +373,7 @@ var unitReleaseCmd = &cobra.Command{ Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { client := sdk.NewClient(serverURL) - unitID := args[0] + unitID := mustResolveUnitID(context.Background(), client, args[0]) inputFile := args[1] printVerbose("Releasing unit: %s", unitID) lockID := getLockID(unitID) diff --git a/taco/internal/api/internal.go b/taco/internal/api/internal.go index f4546c01b..f40d4274b 100644 --- a/taco/internal/api/internal.go +++ b/taco/internal/api/internal.go @@ -36,18 +36,9 @@ func RegisterInternalRoutes(e *echo.Echo, deps Dependencies) { internal := e.Group("/internal/api") internal.Use(middleware.WebhookAuth()) - // Add org resolution middleware - resolves org name to UUID and adds to domain context - if deps.QueryStore != nil { - if db := repositories.GetDBFromQueryStore(deps.QueryStore); db != nil { - // Create identifier resolver (infrastructure layer) - identifierResolver := repositories.NewIdentifierResolver(db) - // Pass interface to middleware (clean architecture!) - internal.Use(middleware.ResolveOrgContextMiddleware(identifierResolver)) - log.Println("Org context resolution middleware enabled for internal routes") - } else { - log.Println("WARNING: QueryStore does not implement GetDB() *gorm.DB - org resolution disabled") - } - } + // Validate org UUID from webhook header and add to domain context + internal.Use(middleware.WebhookOrgUUIDMiddleware()) + log.Println("Org UUID validation middleware enabled for internal routes") // Organization and User management endpoints if orgRepo != nil && userRepo != nil { diff --git a/taco/internal/api/routes.go b/taco/internal/api/routes.go index 8d069aa92..aff68f40b 100644 --- a/taco/internal/api/routes.go +++ b/taco/internal/api/routes.go @@ -23,6 +23,7 @@ import ( "github.com/diggerhq/digger/opentaco/internal/storage" "github.com/diggerhq/digger/opentaco/internal/sts" unithandlers "github.com/diggerhq/digger/opentaco/internal/unit" + "gorm.io/gorm" "github.com/labstack/echo/v4" ) @@ -87,6 +88,17 @@ func RegisterRoutes(e *echo.Echo, deps Dependencies) { // Opaque API tokens for TFE surface (uses blob store for storage) apiTokenMgr := authpkg.NewAPITokenManagerFromStore(deps.BlobStore) authHandler.SetAPITokenManager(apiTokenMgr) + + // Inject DB and org repo for auto-creating user orgs + if deps.QueryStore != nil { + sqlStore, ok := deps.QueryStore.(interface{ GetDB() *gorm.DB }) + orgRepo := repositories.NewOrgRepositoryFromQueryStore(deps.QueryStore) + if ok && orgRepo != nil { + authHandler.SetDB(sqlStore.GetDB()) + authHandler.SetOrgRepo(orgRepo) + log.Println("Auth handler configured with DB and org repo for auto-org creation") + } + } e.POST("/v1/auth/exchange", authHandler.Exchange) e.POST("/v1/auth/token", authHandler.Token) @@ -148,13 +160,13 @@ func RegisterRoutes(e *echo.Echo, deps Dependencies) { jwtVerifyFn := middleware.JWTOnlyVerifier(deps.Signer) v1.Use(middleware.RequireAuth(jwtVerifyFn, deps.Signer)) - // Add JWT org resolution middleware (converts org name from JWT to UUID in domain context) - if identifierResolver != nil { - v1.Use(middleware.JWTOrgResolverMiddleware(identifierResolver)) - log.Println("JWT org resolver middleware enabled for /v1 routes") - } else { - log.Println("WARNING: QueryStore does not implement GetDB() *gorm.DB - JWT org resolution disabled") - } + // Extract org UUID directly from JWT claims (no name resolution) + v1.Use(middleware.JWTOrgUUIDMiddleware()) + log.Println("JWT org UUID middleware enabled for /v1 routes") + } else { + // When auth is disabled, inject system org UUID + v1.Use(middleware.SystemOrgMiddleware()) + log.Printf("Auth disabled - using system organization (%s)", domain.SystemOrgUUID) } // Unit handlers (management API) - uses UnitManagement interface (11 methods) @@ -253,6 +265,9 @@ func RegisterRoutes(e *echo.Echo, deps Dependencies) { if deps.AuthEnabled { opaqueVerifyFn := middleware.OpaqueOnlyVerifier(apiTokenMgr) tfeGroup.Use(middleware.RequireAuth(opaqueVerifyFn, deps.Signer)) + } else { + // When auth is disabled, inject system org UUID + tfeGroup.Use(middleware.SystemOrgMiddleware()) } // Move TFE endpoints to protected group diff --git a/taco/internal/auth/handler.go b/taco/internal/auth/handler.go index 5cda3e814..0ab1ba100 100644 --- a/taco/internal/auth/handler.go +++ b/taco/internal/auth/handler.go @@ -10,17 +10,21 @@ import ( "strings" "time" + "github.com/diggerhq/digger/opentaco/internal/domain" "github.com/diggerhq/digger/opentaco/internal/oidc" "github.com/diggerhq/digger/opentaco/internal/sts" "github.com/labstack/echo/v4" + "gorm.io/gorm" ) // Handler provides auth-related HTTP handlers. type Handler struct{ - signer *Signer - sts sts.Issuer - oidcV oidc.Verifier - apiTokens *APITokenManager + signer *Signer + sts sts.Issuer + oidcV oidc.Verifier + apiTokens *APITokenManager + db *gorm.DB + orgRepo domain.OrganizationRepository } func NewHandlerFromEnv() *Handler { @@ -44,6 +48,14 @@ func (h *Handler) SetAPITokenManager(m *APITokenManager) { h.apiTokens = m } +func (h *Handler) SetDB(db *gorm.DB) { + h.db = db +} + +func (h *Handler) SetOrgRepo(orgRepo domain.OrganizationRepository) { + h.orgRepo = orgRepo +} + // Exchange handles POST /v1/auth/exchange // Request: {"id_token":"..."} // Response: {"access_token":"...","refresh_token":"...","expires_in":3600,"token_type":"Bearer"} @@ -65,7 +77,21 @@ func (h *Handler) Exchange(c echo.Context) error { // Extract email from ID token if available email := extractEmailFromIDToken(req.IDToken) - access, exp, err := h.signer.MintAccessWithEmail(sub, email, nil, groups, []string{"api","s3"}) + // Ensure user has an organization (auto-create if none) and include it in the JWT + var orgID string + if h.db != nil && h.orgRepo != nil { + if o, errOrg := h.ensureUserHasOrg(c.Request().Context(), sub, email); errOrg == nil { + orgID = o + } + } + + var access string + var exp time.Time + if orgID != "" { + access, exp, err = h.signer.MintAccessWithOrg(sub, email, nil, groups, []string{"api","s3"}, orgID) + } else { + access, exp, err = h.signer.MintAccessWithEmail(sub, email, nil, groups, []string{"api","s3"}) + } if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error":"sign_error"}) } rid := randomRID() refresh, _, err := h.signer.MintRefresh(sub, rid) diff --git a/taco/internal/auth/jwt.go b/taco/internal/auth/jwt.go index 1c73a355f..90599bc68 100644 --- a/taco/internal/auth/jwt.go +++ b/taco/internal/auth/jwt.go @@ -93,7 +93,7 @@ type accessClaims struct { Roles []string `json:"roles,omitempty"` Groups []string `json:"groups,omitempty"` Scopes []string `json:"scopes,omitempty"` - Org string `json:"org,omitempty"` + Org string `json:"org_uuid,omitempty"` Email string `json:"email,omitempty"` jwt.RegisteredClaims } @@ -108,6 +108,7 @@ type oauthCodeClaims struct { RedirectURI string `json:"redirect_uri"` Email string `json:"email,omitempty"` Groups []string `json:"groups,omitempty"` + Org string `json:"org_uuid,omitempty"` CodeChallenge string `json:"code_challenge"` jwt.RegisteredClaims } @@ -190,14 +191,15 @@ func (s *Signer) MintRefresh(sub, rid string) (string, time.Time, error) { } // MintOAuthCode creates a JWT authorization code for OAuth flows (5-minute expiry) -func (s *Signer) MintOAuthCode(sub, email, clientID, redirectURI, codeChallenge string, groups []string) (string, time.Time, error) { +func (s *Signer) MintOAuthCode(sub, email, clientID, redirectURI, codeChallenge, org string, groups []string) (string, time.Time, error) { now := time.Now() - exp := now.Add(5 * time.Minute) // OAuth codes expire quickly + exp := now.Add(5 * time.Minute) claims := oauthCodeClaims{ ClientID: clientID, RedirectURI: redirectURI, Email: email, Groups: groups, + Org: org, CodeChallenge: codeChallenge, RegisteredClaims: jwt.RegisteredClaims{ Issuer: s.issuer, diff --git a/taco/internal/auth/terraform.go b/taco/internal/auth/terraform.go index 06b3b2832..25d4e08d1 100644 --- a/taco/internal/auth/terraform.go +++ b/taco/internal/auth/terraform.go @@ -1,6 +1,7 @@ package auth import ( + "context" "crypto/rand" "crypto/sha256" "encoding/base64" @@ -40,6 +41,7 @@ type AuthCode struct { Email string `json:"email"` Groups []string `json:"groups"` CodeChallenge string `json:"code_challenge"` + Org string `json:"org"` } // OAuthSession represents OAuth session data encoded in encrypted state @@ -49,6 +51,7 @@ type OAuthSession struct { State string `json:"state"` CodeChallenge string `json:"code_challenge"` // Terraform's original challenge ServerCodeVerifier string `json:"server_code_verifier"` // OpenTaco's code verifier for Okta + Org string `json:"org"` // Organization UUID from CLI } // TerraformServiceDiscovery handles /.well-known/terraform.json @@ -174,23 +177,25 @@ func (h *Handler) OAuthToken(c echo.Context) error { var exp time.Time if ttlStr := getenv("OPENTACO_TERRAFORM_TOKEN_TTL", ""); ttlStr != "" { if ttl, err := time.ParseDuration(ttlStr); err == nil { - accessToken, exp, err = h.signer.MintAccessWithEmailAndTTL( + accessToken, exp, err = h.signer.MintAccessWithOrgAndTTL( authCode.Subject, authCode.Email, nil, authCode.Groups, []string{"api", "s3"}, + authCode.Org, ttl, ) } } if accessToken == "" { - accessToken, exp, err = h.signer.MintAccessWithEmail( + accessToken, exp, err = h.signer.MintAccessWithOrg( authCode.Subject, authCode.Email, nil, authCode.Groups, []string{"api", "s3"}, + authCode.Org, ) } if err != nil { @@ -201,9 +206,12 @@ func (h *Handler) OAuthToken(c echo.Context) error { // Issue an opaque API token for TFE compatibility (like real Terraform Cloud) if h.apiTokens != nil { - // Default to "default" org for self-hosted (TODO: extract from JWT org claim for multi-tenant) - orgID := "default" - if opaque, err2 := h.apiTokens.Issue(c.Request().Context(), orgID, authCode.Subject, authCode.Email, authCode.Groups); err2 == nil { + org := authCode.Org + if org == "" { + return echo.NewHTTPError(http.StatusBadRequest, "org_uuid required in token claims") + } + + if opaque, err2 := h.apiTokens.Issue(c.Request().Context(), org, authCode.Subject, authCode.Email, authCode.Groups); err2 == nil { // Return opaque token as access_token (matching TFE behavior) // Calculate expiration based on TERRAFORM_TOKEN_TTL or default to very long var expiresIn int @@ -244,7 +252,8 @@ func (h *Handler) OAuthLoginRedirect(c echo.Context) error { clientID := c.QueryParam("client_id") redirectURI := c.QueryParam("redirect_uri") state := c.QueryParam("state") - terraformCodeChallenge := c.QueryParam("code_challenge") // Terraform's original challenge + terraformCodeChallenge := c.QueryParam("code_challenge") + org := c.QueryParam("org") // Get OIDC configuration serverConfig, err := h.getServerAuthConfig() @@ -264,13 +273,14 @@ func (h *Handler) OAuthLoginRedirect(c echo.Context) error { serverCodeVerifier := generateCodeVerifier() serverCodeChallenge := generateCodeChallenge(serverCodeVerifier) - // Create OAuth session data (store both Terraform's and server PKCE data) + // Create OAuth session data sessionData := &OAuthSession{ ClientID: clientID, RedirectURI: redirectURI, State: state, - CodeChallenge: terraformCodeChallenge, // Store Terraform's original challenge for final verification - ServerCodeVerifier: serverCodeVerifier, // Store server code verifier for Okta token exchange + CodeChallenge: terraformCodeChallenge, + ServerCodeVerifier: serverCodeVerifier, + Org: org, } // Encrypt the session data into the state parameter @@ -357,6 +367,15 @@ func (h *Handler) OAuthOIDCCallback(c echo.Context) error { email := extractEmailFromIDToken(tokenResp.IDToken) + // Ensure user has an org + log.Printf("[OAuth] About to ensure org for user: %s", subject) + org, err := h.ensureUserHasOrg(c.Request().Context(), subject, email) + if err != nil { + log.Printf("[OAuth] Failed to ensure user org: %v", err) + return c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to ensure user org: %v", err)) + } + log.Printf("[OAuth] User org ensured: %s", org) + // Create authorization code for Terraform authCodeData := &AuthCode{ ClientID: sessionData.ClientID, @@ -365,8 +384,11 @@ func (h *Handler) OAuthOIDCCallback(c echo.Context) error { Email: email, Groups: groups, CodeChallenge: sessionData.CodeChallenge, + Org: org, } + log.Printf("[OAuth] Creating auth code with org: %s", org) + authCode, err := h.createAuthCode(authCodeData) if err != nil { return c.String(http.StatusInternalServerError, fmt.Sprintf("Failed to create auth code: %v", err)) @@ -621,6 +643,7 @@ func (h *Handler) createAuthCode(data *AuthCode) (string, error) { data.ClientID, data.RedirectURI, data.CodeChallenge, + data.Org, data.Groups, ) if err != nil { @@ -650,6 +673,7 @@ func (h *Handler) verifyAuthCode(code string) (*AuthCode, error) { Email: claims.Email, Groups: claims.Groups, CodeChallenge: claims.CodeChallenge, + Org: claims.Org, } return authCode, nil @@ -767,3 +791,58 @@ func (h *Handler) exchangeOIDCCode(config *serverAuthConfig, code, redirectURI, return &tokenResp, nil } + +// ensureUserHasOrg ensures a user exists and has an org, auto-creating both if needed +func (h *Handler) ensureUserHasOrg(ctx context.Context, subject, email string) (string, error) { + if h.db == nil || h.orgRepo == nil { + return "", fmt.Errorf("db or org repository not configured") + } + + orgs, err := h.getUserOrganizations(ctx, subject) + if err != nil { + return "", fmt.Errorf("failed to get user orgs: %w", err) + } + + if len(orgs) > 0 { + return orgs[0], nil + } + + // If user has no RBAC membership yet, reuse any org they previously created + // This avoids creating multiple orgs before membership is assigned + var existing struct{ ID string } + if err := h.db.WithContext(ctx). + Table("organizations"). + Select("id"). + Where("created_by = ?", subject). + Order("created_at DESC"). + First(&existing).Error; err == nil && existing.ID != "" { + return existing.ID, nil + } + + orgName := fmt.Sprintf("user-%s", subject[:min(8, len(subject))]) + orgDisplayName := fmt.Sprintf("%s's Organization", email) + + org, err := h.orgRepo.Create(ctx, orgName, orgDisplayName, subject) + if err != nil { + return "", fmt.Errorf("failed to create org: %w", err) + } + + return org.ID, nil +} + +func (h *Handler) getUserOrganizations(ctx context.Context, subject string) ([]string, error) { + var userID string + if err := h.db.WithContext(ctx).Table("users").Where("subject = ?", subject).Pluck("id", &userID).Error; err != nil { + return nil, err + } + if userID == "" { + return []string{}, nil + } + + var orgIDs []string + if err := h.db.WithContext(ctx).Table("user_roles").Where("user_id = ?", userID).Pluck("DISTINCT org_id", &orgIDs).Error; err != nil { + return nil, err + } + + return orgIDs, nil +} diff --git a/taco/internal/domain/identifier_resolver.go b/taco/internal/domain/identifier_resolver.go index 209f524c2..96218373a 100644 --- a/taco/internal/domain/identifier_resolver.go +++ b/taco/internal/domain/identifier_resolver.go @@ -2,23 +2,17 @@ package domain import "context" -// IdentifierResolver resolves human-readable identifiers (names, org-scoped names) -// or UUIDs to their canonical UUID form. -// This is a domain interface - implementations live in the infrastructure layer. +// IdentifierResolver resolves identifiers to UUIDs type IdentifierResolver interface { - // ResolveOrganization resolves an org identifier (name or UUID) to UUID + // ResolveOrganization accepts UUID or external ID (format: "ext:provider:id") + // Does NOT accept names (names are not unique) ResolveOrganization(ctx context.Context, identifier string) (string, error) - // ResolveUnit resolves a unit identifier to UUID within an organization + // ResolveUnit accepts UUID, name (org-scoped), or absolute name ResolveUnit(ctx context.Context, identifier, orgID string) (string, error) - // ResolveRole resolves a role identifier to UUID within an organization ResolveRole(ctx context.Context, identifier, orgID string) (string, error) - - // ResolvePermission resolves a permission identifier to UUID within an organization ResolvePermission(ctx context.Context, identifier, orgID string) (string, error) - - // ResolveTag resolves a tag identifier to UUID within an organization ResolveTag(ctx context.Context, identifier, orgID string) (string, error) } diff --git a/taco/internal/domain/organization.go b/taco/internal/domain/organization.go index 9c7a7fc1e..dec764694 100644 --- a/taco/internal/domain/organization.go +++ b/taco/internal/domain/organization.go @@ -8,6 +8,11 @@ import ( "time" ) +const ( + // SystemOrgUUID is the well-known UUID used when auth is disabled + SystemOrgUUID = "00000000-0000-0000-0000-000000000000" +) + var ( ErrOrgExists = errors.New("organization already exists") ErrOrgNotFound = errors.New("organization not found") @@ -22,11 +27,11 @@ var OrgIDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*[a-zA-Z0-9]$`) // ============================================ // Organization represents an organization in the domain layer -// This is the domain model, separate from database entities type Organization struct { - ID string // UUID (primary key, for API) - Name string // Unique identifier (e.g., "acme") - used in CLI and paths - DisplayName string // Friendly name (e.g., "Acme Corp") - shown in UI + ID string + Name string // Non-unique display name + ExternalID *string // Optional unique external identifier + DisplayName string CreatedBy string CreatedAt time.Time UpdatedAt time.Time @@ -37,12 +42,11 @@ type Organization struct { // ============================================ // OrganizationRepository defines the interface for organization data access -// Implementations live in the repositories package type OrganizationRepository interface { - Create(ctx context.Context, orgID, name, createdBy string) (*Organization, error) - Get(ctx context.Context, orgID string) (*Organization, error) + Create(ctx context.Context, name, displayName, createdBy string) (*Organization, error) + Get(ctx context.Context, orgUUID string) (*Organization, error) List(ctx context.Context) ([]*Organization, error) - Delete(ctx context.Context, orgID string) error + Delete(ctx context.Context, orgUUID string) error WithTransaction(ctx context.Context, fn func(ctx context.Context, txRepo OrganizationRepository) error) error } @@ -53,8 +57,8 @@ type OrganizationRepository interface { // User represents a user in the domain layer type User struct { - ID string // UUID - Subject string // Unique identifier (email, auth0 ID, etc.) + ID string + Subject string Email string CreatedAt time.Time UpdatedAt time.Time diff --git a/taco/internal/middleware/auth.go b/taco/internal/middleware/auth.go index da4f7d713..ed37c5135 100644 --- a/taco/internal/middleware/auth.go +++ b/taco/internal/middleware/auth.go @@ -1,7 +1,6 @@ package middleware import ( - "context" "net/http" "strings" @@ -44,9 +43,8 @@ func RequireAuth(verify AccessTokenVerifier, signer *auth.Signer) echo.Middlewar ctx := rbac.ContextWithPrincipal(c.Request().Context(), p) c.SetRequest(c.Request().WithContext(ctx)) - // Store org from JWT for org context middleware - // Always set jwt_org (even if empty) so downstream middleware can default consistently - c.Set("jwt_org", claims.Org) + // Store org UUID from JWT for org context middleware + c.Set("jwt_org_uuid", claims.Org) } else { // Fallback to generic verify function if no signer if err := verify(token); err != nil { @@ -119,15 +117,17 @@ func getPrincipalFromToken(c echo.Context, signer *auth.Signer, apiTokenMgr *aut // Fallback to opaque token if apiTokenMgr != nil { - // Extract org from context or default to "default" - orgID := getOrgIDFromContext(c, "default") - if tokenRecord, err := apiTokenMgr.Verify(c.Request().Context(), orgID, token); err == nil { - return rbac.Principal{ - Subject: tokenRecord.Subject, - Email: tokenRecord.Email, - Roles: []string{}, // Opaque tokens don't have roles directly - Groups: tokenRecord.Groups, - }, nil + // Extract org UUID from context + orgID := getOrgIDFromContext(c, "") + if orgID != "" { + if tokenRecord, err := apiTokenMgr.Verify(c.Request().Context(), orgID, token); err == nil { + return rbac.Principal{ + Subject: tokenRecord.Subject, + Email: tokenRecord.Email, + Roles: []string{}, + Groups: tokenRecord.Groups, + }, nil + } } } @@ -199,12 +199,10 @@ func OpaqueOnlyVerifier(apiTokenMgr *auth.APITokenManager) AccessTokenVerifier { return echo.NewHTTPError(http.StatusInternalServerError, "API token manager not configured") } - // Default to "default" org (no context available in this verifier) - if _, err := apiTokenMgr.Verify(context.Background(), "default", token); err != nil { - return echo.ErrUnauthorized - } - - return nil + // Note: Opaque token verification without org context + // This is a limitation - opaque tokens should include org info + // For now, return unauthorized if we can't verify + return echo.ErrUnauthorized } } @@ -321,8 +319,12 @@ func getPrincipalFromOpaque(c echo.Context, apiTokenMgr *auth.APITokenManager) ( return rbac.Principal{}, echo.NewHTTPError(http.StatusInternalServerError, "API token manager not configured") } - // Extract org from context or default to "default" - orgID := getOrgIDFromContext(c, "default") + // Extract org UUID from context + orgID := getOrgIDFromContext(c, "") + if orgID == "" { + return rbac.Principal{}, echo.NewHTTPError(http.StatusUnauthorized, "org context required for opaque token") + } + tokenRecord, err := apiTokenMgr.Verify(c.Request().Context(), orgID, token) if err != nil { return rbac.Principal{}, echo.NewHTTPError(http.StatusUnauthorized, "invalid opaque token") @@ -336,10 +338,10 @@ func getPrincipalFromOpaque(c echo.Context, apiTokenMgr *auth.APITokenManager) ( }, nil } -// getOrgIDFromContext extracts org ID from Echo context or returns a default value +// getOrgIDFromContext extracts org UUID from Echo context or returns a default value func getOrgIDFromContext(c echo.Context, defaultOrg string) string { - // Try to get from jwt_org (set by RequireAuth middleware) - if jwtOrg := c.Get("jwt_org"); jwtOrg != nil { + // Try to get from jwt_org_uuid (set by RequireAuth middleware) + if jwtOrg := c.Get("jwt_org_uuid"); jwtOrg != nil { if orgStr, ok := jwtOrg.(string); ok && orgStr != "" { return orgStr } diff --git a/taco/internal/middleware/org_context.go b/taco/internal/middleware/org_context.go index 85c9d9fbe..14dc24eb0 100644 --- a/taco/internal/middleware/org_context.go +++ b/taco/internal/middleware/org_context.go @@ -6,31 +6,19 @@ import ( "log" ) -const DefaultOrgID = "default" - -// JWTOrgResolverMiddleware resolves org name from JWT claims to UUID and adds to domain context -// This should be used AFTER RequireAuth middleware for JWT-authenticated routes -func JWTOrgResolverMiddleware(resolver domain.IdentifierResolver) echo.MiddlewareFunc { +// JWTOrgUUIDMiddleware extracts org UUID from JWT and adds to domain context +// For CLI routes (v1/, tfe/) - expects org_uuid in JWT claims +func JWTOrgUUIDMiddleware() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - // Get org name from JWT claim (set by RequireAuth middleware) - orgName, ok := c.Get("jwt_org").(string) - if !ok || orgName == "" { - orgName = DefaultOrgID - } - - log.Printf("[JWTOrgResolver] Resolving org name '%s' to UUID", orgName) - - // Resolve org name to UUID - orgUUID, err := resolver.ResolveOrganization(c.Request().Context(), orgName) - if err != nil { - log.Printf("[JWTOrgResolver] Failed to resolve organization '%s': %v", orgName, err) - return echo.NewHTTPError(500, "Failed to resolve organization") + orgUUID, ok := c.Get("jwt_org_uuid").(string) + if !ok || orgUUID == "" { + log.Printf("[JWTOrgUUID] No org_uuid in JWT claims") + return echo.NewHTTPError(400, "Organization UUID not found in token") } - log.Printf("[JWTOrgResolver] Successfully resolved '%s' to UUID: %s", orgName, orgUUID) + log.Printf("[JWTOrgUUID] Found org UUID: %s", orgUUID) - // Add to domain context ctx := domain.ContextWithOrg(c.Request().Context(), orgUUID) c.SetRequest(c.Request().WithContext(ctx)) @@ -39,44 +27,40 @@ func JWTOrgResolverMiddleware(resolver domain.IdentifierResolver) echo.Middlewar } } -// ResolveOrgContextMiddleware resolves org name to UUID and adds to domain context -// This should be used AFTER WebhookAuth for internal routes -func ResolveOrgContextMiddleware(resolver domain.IdentifierResolver) echo.MiddlewareFunc { +// WebhookOrgUUIDMiddleware extracts org UUID from webhook header and adds to domain context +// For internal routes (/internal/api/*) - expects UUID in X-Org-ID header +func WebhookOrgUUIDMiddleware() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - log.Printf("[WebhookOrgResolver] MIDDLEWARE INVOKED for path: %s", c.Path()) - - // Get org name from echo context (set by WebhookAuth) - orgName, ok := c.Get("organization_id").(string) - if !ok { - log.Printf("[WebhookOrgResolver] WARNING: organization_id not found in context, defaulting to 'default'") - orgName = DefaultOrgID - } else if orgName == "" { - log.Printf("[WebhookOrgResolver] WARNING: organization_id is empty, defaulting to 'default'") - orgName = DefaultOrgID - } else { - log.Printf("[WebhookOrgResolver] Found organization_id in context: '%s'", orgName) + orgUUID, ok := c.Get("organization_id").(string) + if !ok || orgUUID == "" { + log.Printf("[WebhookOrgUUID] organization_id not found in context") + return echo.NewHTTPError(400, "X-Org-ID header is required") } - log.Printf("[WebhookOrgResolver] Resolving org name '%s' to UUID", orgName) - - // Resolve org name to UUID - orgUUID, err := resolver.ResolveOrganization(c.Request().Context(), orgName) - if err != nil { - log.Printf("[WebhookOrgResolver] ERROR: Failed to resolve organization '%s': %v", orgName, err) - return echo.NewHTTPError(500, "Failed to resolve organization") + if !domain.IsUUID(orgUUID) { + log.Printf("[WebhookOrgUUID] organization_id is not a UUID: %s", orgUUID) + return echo.NewHTTPError(400, "X-Org-ID must be a UUID") } - log.Printf("[WebhookOrgResolver] SUCCESS: Resolved '%s' to UUID: %s", orgName, orgUUID) + log.Printf("[WebhookOrgUUID] Found org UUID: %s", orgUUID) - // Add to domain context ctx := domain.ContextWithOrg(c.Request().Context(), orgUUID) c.SetRequest(c.Request().WithContext(ctx)) - log.Printf("[WebhookOrgResolver] Domain context updated with org UUID") - return next(c) } } } +// SystemOrgMiddleware injects the system org UUID when auth is disabled +// This provides org context for all operations without requiring authentication +func SystemOrgMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := domain.ContextWithOrg(c.Request().Context(), domain.SystemOrgUUID) + c.SetRequest(c.Request().WithContext(ctx)) + return next(c) + } + } +} diff --git a/taco/internal/middleware/webhook.go b/taco/internal/middleware/webhook.go index 0aab59519..70329fdf4 100644 --- a/taco/internal/middleware/webhook.go +++ b/taco/internal/middleware/webhook.go @@ -7,19 +7,16 @@ import ( "log/slog" "crypto/subtle" - "github.com/diggerhq/digger/opentaco/internal/domain" "github.com/diggerhq/digger/opentaco/internal/rbac" "github.com/labstack/echo/v4" ) // WebhookAuth returns middleware that verifies webhook secret and extracts user context -// This extracts data and delegates to domain/repository layers // Expects headers: // - Authorization: Bearer // - X-User-ID: user identifier (subject) // - X-Email: user email -// - X-Org-ID: organization identifier (defaults to "default" for self-hosted) -// Note: Org validation and UUID resolution happens in ResolveOrgContextMiddleware +// - X-Org-ID: organization UUID (required, must be valid UUID) func WebhookAuth() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { @@ -57,7 +54,6 @@ func WebhookAuth() echo.MiddlewareFunc { }) } - // Extract user context from headers userID := c.Request().Header.Get("X-User-ID") email := c.Request().Header.Get("X-Email") orgID := c.Request().Header.Get("X-Org-ID") @@ -65,54 +61,36 @@ func WebhookAuth() echo.MiddlewareFunc { // Skip org validation for create org endpoint isCreateOrg := c.Request().Method == http.MethodPost && c.Path() == "/internal/api/orgs" - // Require user ID if userID == "" { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "X-User-ID header required", }) } - // Default to "default" org if not provided (except for create org endpoint) - if !isCreateOrg && orgID == "" { - orgID = "default" - slog.Debug("No X-Org-ID header provided, defaulting to 'default' org") - } - - // Validate org ID format (prevents injection/traversal attacks) - if orgID != "" && !domain.OrgIDPattern.MatchString(orgID) { - return c.JSON(http.StatusBadRequest, map[string]string{ - "error": "invalid org ID format", - }) + // Require org UUID for all requests except create org + if !isCreateOrg { + if orgID == "" { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "X-Org-ID header required", + }) + } } - // Note: Org existence validation and resolution to UUID happens in - // ResolveOrgContextMiddleware which runs after this middleware. - // This middleware just extracts the org name from headers. - - // Build Principal from headers (for RBAC layer) - // Note: RBAC IS applied if enabled - it looks up roles from database using Subject - principal := rbac.Principal{ - Subject: userID, - Email: email, - Roles: []string{}, // Roles are looked up from DB, not passed via headers - Groups: []string{"org:" + orgID}, // Org group for permission matching - } + principal := rbac.Principal{ + Subject: userID, + Email: email, + Roles: []string{}, + Groups: []string{"org:" + orgID}, + } - // Store in echo context for downstream middleware - c.Set("organization_id", orgID) // ResolveOrgContextMiddleware will resolve this to UUID + c.Set("organization_id", orgID) c.Set("user_id", userID) c.Set("email", email) - // Add principal context (for authorizingRepository/RBAC) ctx := c.Request().Context() ctx = rbac.ContextWithPrincipal(ctx, principal) c.SetRequest(c.Request().WithContext(ctx)) - // NOTE: Org context is NOT set here - ResolveOrgContextMiddleware will: - // 1. Read orgID from c.Get("organization_id") - // 2. Resolve org name "default" to its UUID - // 3. Add UUID to domain context via domain.ContextWithOrg() - return next(c) } } diff --git a/taco/internal/observability/sync_health.go b/taco/internal/observability/sync_health.go index 03991398d..aaa0e3ece 100644 --- a/taco/internal/observability/sync_health.go +++ b/taco/internal/observability/sync_health.go @@ -45,8 +45,8 @@ func (h *SyncHealthChecker) CheckSyncHealth(ctx context.Context) *SyncHealthStat // Get units from repository (uses query index) // This also checks database connectivity - // Use default org for health check - queryUnits, err := h.repo.List(ctx, "default", "") + // Note: without org context, this may return empty list, but validates connectivity + queryUnits, err := h.repo.List(ctx, "", "") if err != nil { status.Healthy = false status.Message = "Database unavailable (critical for RBAC and fast listing): " + err.Error() diff --git a/taco/internal/query/common/sql_store.go b/taco/internal/query/common/sql_store.go index 255ae9fdd..7309bb2d6 100644 --- a/taco/internal/query/common/sql_store.go +++ b/taco/internal/query/common/sql_store.go @@ -150,48 +150,33 @@ func (s *SQLStore) GetUnit(ctx context.Context, id string) (*types.Unit, error) return &unit, nil } -// parseBlobPath parses a blob path into org and unit name -// Supports: "org/name" or "name" (defaults to "default" org) +// parseBlobPath parses a blob path into org UUID and unit name +// Format: "orgUUID/name" (org UUID is required) func (s *SQLStore) parseBlobPath(ctx context.Context, blobPath string) (orgUUID, name string, err error) { parts := strings.SplitN(strings.Trim(blobPath, "/"), "/", 2) - var orgName string - if len(parts) == 2 { - // Format: "org/name" - orgName = parts[0] - name = parts[1] - } else { - // Format: "name" - use default org - orgName = "default" - name = parts[0] + if len(parts) != 2 { + return "", "", fmt.Errorf("blob path must be in format 'orgUUID/name', got: %s", blobPath) } - // Ensure org exists + orgUUID = parts[0] + name = parts[1] + + // Validate org UUID exists var org types.Organization - err = s.db.WithContext(ctx).Where("name = ?", orgName).First(&org).Error + err = s.db.WithContext(ctx).Where("id = ?", orgUUID).First(&org).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - // Create org if it doesn't exist (for migration) - org = types.Organization{ - Name: orgName, - DisplayName: fmt.Sprintf("Auto-created: %s", orgName), - CreatedBy: "system-sync", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - if err := s.db.WithContext(ctx).Create(&org).Error; err != nil { - return "", "", fmt.Errorf("failed to create org: %w", err) - } - } else { - return "", "", fmt.Errorf("failed to lookup org: %w", err) + return "", "", fmt.Errorf("organization not found: %s", orgUUID) } + return "", "", fmt.Errorf("failed to lookup org: %w", err) } - return org.ID, name, nil + return orgUUID, name, nil } // SyncEnsureUnit creates or updates a unit from blob storage -// Supports blob paths: "org/name" or "name" (defaults to default org) +// Blob path format: "orgUUID/name" // UUIDs are auto-generated via BeforeCreate hook func (s *SQLStore) SyncEnsureUnit(ctx context.Context, unitName string) error { orgUUID, name, err := s.parseBlobPath(ctx, unitName) diff --git a/taco/internal/query/types/models.go b/taco/internal/query/types/models.go index cf4e6b6df..6c83d8003 100644 --- a/taco/internal/query/types/models.go +++ b/taco/internal/query/types/models.go @@ -106,10 +106,11 @@ func (rut *RuleUnitTag) BeforeCreate(tx *gorm.DB) error { } type Organization struct { - ID string `gorm:"type:varchar(36);primaryKey"` - Name string `gorm:"type:varchar(255);not null;uniqueIndex"` // Unique identifier (e.g., "acme") - used in CLI and paths - DisplayName string `gorm:"type:varchar(255);not null"` // Friendly name (e.g., "Acme Corp") - shown in UI - CreatedBy string `gorm:"type:varchar(255);not null"` + ID string `gorm:"type:varchar(36);primaryKey"` + Name string `gorm:"type:varchar(255);not null"` // Non-unique display name + ExternalID *string `gorm:"type:varchar(255);uniqueIndex"` // Unique external identifier (optional) + DisplayName string `gorm:"type:varchar(255);not null"` + CreatedBy string `gorm:"type:varchar(255);not null"` CreatedAt time.Time UpdatedAt time.Time } diff --git a/taco/internal/rbac/querystore.go b/taco/internal/rbac/querystore.go index 811f733bc..e602f9118 100644 --- a/taco/internal/rbac/querystore.go +++ b/taco/internal/rbac/querystore.go @@ -35,8 +35,8 @@ func (s *queryRBACStore) CreatePermission(ctx context.Context, perm *Permission) } typePerm := types.Permission{ - OrgID: perm.OrgID, // ✅ FIX: Set org_id for org-scoped RBAC - Name: perm.ID, // "unit-read" (identifier, NOT UUID) + OrgID: perm.OrgID, + Name: perm.ID, // "unit-read" (identifier) - ID auto-generated by GORM Description: description, CreatedBy: perm.CreatedBy, CreatedAt: perm.CreatedAt, @@ -102,8 +102,8 @@ func (s *queryRBACStore) CreateRole(ctx context.Context, role *Role) error { } typeRole := types.Role{ - OrgID: role.OrgID, // ✅ FIX: Set org_id for org-scoped RBAC - Name: role.ID, // "admin" (identifier, NOT UUID) + OrgID: role.OrgID, + Name: role.ID, // "admin" (identifier) - ID auto-generated by GORM Description: description, CreatedBy: role.CreatedBy, CreatedAt: role.CreatedAt, diff --git a/taco/internal/rbac/rbac.go b/taco/internal/rbac/rbac.go index c096907c8..6cb8ec0a9 100644 --- a/taco/internal/rbac/rbac.go +++ b/taco/internal/rbac/rbac.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" "regexp" "strings" "time" @@ -173,6 +174,7 @@ func NewRBACManagerFromQueryStore(queryStore interface{}) (*RBACManager, error) // RBAC is considered "initialized" and "enabled" when permissions exist in the system. // This function is idempotent - it can be called multiple times safely. func (m *RBACManager) InitializeRBAC(ctx context.Context, orgID, initUser, initEmail string) error { + log.Printf("[RBAC Init] Starting for org=%s user=%s", orgID, initUser) // For InitializeRBAC, we need explicit orgID since we're creating the org's RBAC structure // Check if already initialized for this org enabled, err := m.store.ListPermissions(ctx, orgID) @@ -180,6 +182,7 @@ func (m *RBACManager) InitializeRBAC(ctx context.Context, orgID, initUser, initE return fmt.Errorf("failed to check if RBAC is enabled: %w", err) } if len(enabled) > 0 { + log.Printf("[RBAC Init] Already initialized for org=%s, skipping", orgID) // Already initialized - just ensure the init user has admin role if err := m.store.AssignRole(ctx, orgID, initUser, initEmail, "admin"); err != nil { // Ignore duplicate assignment errors @@ -187,6 +190,7 @@ func (m *RBACManager) InitializeRBAC(ctx context.Context, orgID, initUser, initE } return nil } + log.Printf("[RBAC Init] Creating permissions and roles for org=%s", orgID) // Create default permissions (org-scoped) defaultPermission := &Permission{ diff --git a/taco/internal/repositories/default_org.go b/taco/internal/repositories/default_org.go deleted file mode 100644 index e2631d856..000000000 --- a/taco/internal/repositories/default_org.go +++ /dev/null @@ -1,58 +0,0 @@ -package repositories - -import ( - "context" - "errors" - "log" - - "github.com/diggerhq/digger/opentaco/internal/query/types" - "gorm.io/gorm" -) - -// EnsureDefaultOrganization ensures a default organization exists for self-hosted deployments -// This is idempotent and safe to call multiple times -// Returns the UUID of the default organization -func EnsureDefaultOrganization(ctx context.Context, db *gorm.DB) (string, error) { - const defaultOrgID = "default" - const defaultOrgName = "Default Organization" - - // Check if default org exists - var org types.Organization - err := db.WithContext(ctx).Where("name = ?", defaultOrgID).First(&org).Error - - if err == nil { - // Default org already exists - log.Printf("Default organization already exists: ID=%s, Name=%s", org.ID, org.Name) - return org.ID, nil - } - - if !errors.Is(err, gorm.ErrRecordNotFound) { - return "", err - } - - // Create default org - defaultOrg := &types.Organization{ - Name: defaultOrgID, - DisplayName: defaultOrgName, - CreatedBy: "system", - } - - if err := db.WithContext(ctx).Create(defaultOrg).Error; err != nil { - return "", err - } - - log.Printf("Created default organization: ID=%s, Name=%s, DisplayName=%s", - defaultOrg.ID, defaultOrg.Name, defaultOrg.DisplayName) - return defaultOrg.ID, nil -} - -// GetDefaultOrgUUID returns the UUID of the default organization -func GetDefaultOrgUUID(ctx context.Context, db *gorm.DB) (string, error) { - var org types.Organization - err := db.WithContext(ctx).Where("name = ?", "default").First(&org).Error - if err != nil { - return "", err - } - return org.ID, nil -} - diff --git a/taco/internal/repositories/identifier_resolver.go b/taco/internal/repositories/identifier_resolver.go index 879b85263..2c42e51af 100644 --- a/taco/internal/repositories/identifier_resolver.go +++ b/taco/internal/repositories/identifier_resolver.go @@ -3,6 +3,7 @@ package repositories import ( "context" "fmt" + "strings" "github.com/diggerhq/digger/opentaco/internal/domain" "gorm.io/gorm" @@ -21,37 +22,57 @@ func NewIdentifierResolver(db *gorm.DB) domain.IdentifierResolver { } // ResolveOrganization resolves organization identifier to UUID +// Accepts: UUID or external ID (format: "ext:provider:id") +// Does NOT accept names (names are not unique) func (r *gormIdentifierResolver) ResolveOrganization(ctx context.Context, identifier string) (string, error) { if r.db == nil { return "", fmt.Errorf("database not available") } - parsed, err := domain.ParseIdentifier(identifier) - if err != nil { - return "", err - } - - // If already a UUID, return it - if parsed.Type == domain.IdentifierTypeUUID { - return parsed.UUID, nil + // Check if it's a UUID + if domain.IsUUID(identifier) { + var org struct{ ID string } + err := r.db.WithContext(ctx). + Table("organizations"). + Select("id"). + Where("id = ?", identifier). + First(&org).Error + + if err != nil { + if err == gorm.ErrRecordNotFound { + return "", fmt.Errorf("organization not found: %s", identifier) + } + return "", err + } + + return identifier, nil } - // Query by name (organizations.name is the short identifier like "default") - var result struct{ ID string } - err = r.db.WithContext(ctx). - Table("organizations"). - Select("id"). - Where("name = ?", parsed.Name). - First(&result).Error - - if err != nil { - if err == gorm.ErrRecordNotFound { - return "", fmt.Errorf("organization not found: %s", parsed.Name) + // Check if it's an external ID (format: "ext:provider:id") + if strings.HasPrefix(identifier, "ext:") { + parts := strings.SplitN(identifier, ":", 3) + if len(parts) != 3 { + return "", fmt.Errorf("invalid external ID format, expected 'ext:provider:id', got: %s", identifier) } - return "", err + + var org struct{ ID string } + err := r.db.WithContext(ctx). + Table("organizations"). + Select("id"). + Where("external_id = ?", identifier). + First(&org).Error + + if err != nil { + if err == gorm.ErrRecordNotFound { + return "", fmt.Errorf("organization not found with external ID: %s", identifier) + } + return "", err + } + + return org.ID, nil } - return result.ID, nil + return "", fmt.Errorf("organization identifier must be UUID or external ID (ext:provider:id), got: %s", identifier) } // ResolveUnit resolves unit identifier to UUID within an organization diff --git a/taco/internal/repositories/org_repository.go b/taco/internal/repositories/org_repository.go index 6076624a6..b5392a281 100644 --- a/taco/internal/repositories/org_repository.go +++ b/taco/internal/repositories/org_repository.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "log/slog" - "strings" "time" "github.com/diggerhq/digger/opentaco/internal/domain" @@ -13,9 +12,6 @@ import ( "gorm.io/gorm" ) -const ( - queryOrgByName = "name = ?" -) // orgRepository implements OrganizationRepository using GORM // This is where the infrastructure concerns live - hidden from domain and handlers @@ -40,30 +36,11 @@ func NewOrgRepositoryFromQueryStore(queryStore interface{}) domain.OrganizationR } // Create creates a new organization -func (r *orgRepository) Create(ctx context.Context, orgID, name, createdBy string) (*domain.Organization, error) { - // Normalize org ID to lowercase for case-insensitivity - orgID = strings.ToLower(strings.TrimSpace(orgID)) - - // Validate org ID format (domain logic) - if err := domain.ValidateOrgID(orgID); err != nil { - return nil, err - } - - // Check if org already exists (infrastructure logic) - var existing types.Organization - err := r.db.WithContext(ctx).Where(queryOrgByName, orgID).First(&existing).Error - if err == nil { - return nil, domain.ErrOrgExists - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fmt.Errorf("failed to check existing org: %w", err) - } - - // Create new org entity +func (r *orgRepository) Create(ctx context.Context, name, displayName, createdBy string) (*domain.Organization, error) { now := time.Now() entity := &types.Organization{ - Name: orgID, - DisplayName: name, + Name: name, + DisplayName: displayName, CreatedBy: createdBy, CreatedAt: now, UpdatedAt: now, @@ -73,16 +50,16 @@ func (r *orgRepository) Create(ctx context.Context, orgID, name, createdBy strin return nil, fmt.Errorf("failed to create organization: %w", err) } - slog.Info("Organization created successfully", - "orgID", orgID, + slog.Info("Organization created", + "uuid", entity.ID, "name", name, - "createdBy", createdBy, + "displayName", displayName, ) - // Convert entity to domain model return &domain.Organization{ ID: entity.ID, Name: entity.Name, + ExternalID: entity.ExternalID, DisplayName: entity.DisplayName, CreatedBy: entity.CreatedBy, CreatedAt: entity.CreatedAt, @@ -90,10 +67,10 @@ func (r *orgRepository) Create(ctx context.Context, orgID, name, createdBy strin }, nil } -// Get retrieves an organization by ID -func (r *orgRepository) Get(ctx context.Context, orgID string) (*domain.Organization, error) { +// Get retrieves an organization by UUID +func (r *orgRepository) Get(ctx context.Context, orgUUID string) (*domain.Organization, error) { var entity types.Organization - err := r.db.WithContext(ctx).Where(queryOrgByName, orgID).First(&entity).Error + err := r.db.WithContext(ctx).Where("id = ?", orgUUID).First(&entity).Error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, domain.ErrOrgNotFound @@ -101,10 +78,10 @@ func (r *orgRepository) Get(ctx context.Context, orgID string) (*domain.Organiza return nil, fmt.Errorf("failed to get organization: %w", err) } - // Convert entity to domain model return &domain.Organization{ ID: entity.ID, Name: entity.Name, + ExternalID: entity.ExternalID, DisplayName: entity.DisplayName, CreatedBy: entity.CreatedBy, CreatedAt: entity.CreatedAt, @@ -120,12 +97,12 @@ func (r *orgRepository) List(ctx context.Context) ([]*domain.Organization, error return nil, fmt.Errorf("failed to list organizations: %w", err) } - // Convert entities to domain models orgs := make([]*domain.Organization, len(entities)) for i, entity := range entities { orgs[i] = &domain.Organization{ ID: entity.ID, Name: entity.Name, + ExternalID: entity.ExternalID, DisplayName: entity.DisplayName, CreatedBy: entity.CreatedBy, CreatedAt: entity.CreatedAt, @@ -136,9 +113,9 @@ func (r *orgRepository) List(ctx context.Context) ([]*domain.Organization, error return orgs, nil } -// Delete deletes an organization -func (r *orgRepository) Delete(ctx context.Context, orgID string) error { - result := r.db.WithContext(ctx).Where(queryOrgByName, orgID).Delete(&types.Organization{}) +// Delete deletes an organization by UUID +func (r *orgRepository) Delete(ctx context.Context, orgUUID string) error { + result := r.db.WithContext(ctx).Where("id = ?", orgUUID).Delete(&types.Organization{}) if result.Error != nil { return fmt.Errorf("failed to delete organization: %w", result.Error) } @@ -146,7 +123,7 @@ func (r *orgRepository) Delete(ctx context.Context, orgID string) error { return domain.ErrOrgNotFound } - slog.Info("Organization deleted", "orgID", orgID) + slog.Info("Organization deleted", "uuid", orgUUID) return nil } @@ -158,3 +135,19 @@ func (r *orgRepository) WithTransaction(ctx context.Context, fn func(ctx context }) } +// EnsureSystemOrganization creates the system org if it doesn't exist (for auth-disabled mode) +func EnsureSystemOrganization(ctx context.Context, db *gorm.DB, systemOrgID string) error { + now := time.Now() + org := types.Organization{ + ID: systemOrgID, + Name: "system", + DisplayName: "System Organization", + CreatedBy: "system", + CreatedAt: now, + UpdatedAt: now, + } + + // Use FirstOrCreate to be idempotent + result := db.WithContext(ctx).Where(types.Organization{ID: systemOrgID}).FirstOrCreate(&org) + return result.Error +} diff --git a/taco/internal/repositories/user_repository.go b/taco/internal/repositories/user_repository.go index 2b62b6913..4f2020392 100644 --- a/taco/internal/repositories/user_repository.go +++ b/taco/internal/repositories/user_repository.go @@ -33,13 +33,10 @@ func NewUserRepositoryFromQueryStore(queryStore interface{}) domain.UserReposito // EnsureUser creates a user if it doesn't exist, or returns existing user (idempotent) func (r *userRepository) EnsureUser(ctx context.Context, subject, email string) (*domain.User, error) { - // Try to get existing user by subject var entity types.User err := r.db.WithContext(ctx).Where("subject = ?", subject).First(&entity).Error if err == nil { - // User exists, return it - slog.Debug("User already exists", "subject", subject) return &domain.User{ ID: entity.ID, Subject: entity.Subject, @@ -53,7 +50,6 @@ func (r *userRepository) EnsureUser(ctx context.Context, subject, email string) return nil, fmt.Errorf("failed to check existing user: %w", err) } - // User doesn't exist, create it now := time.Now() entity = types.User{ Subject: subject, @@ -67,10 +63,7 @@ func (r *userRepository) EnsureUser(ctx context.Context, subject, email string) return nil, fmt.Errorf("failed to create user: %w", err) } - slog.Info("User created successfully", - "subject", subject, - "email", email, - ) + slog.Info("User created", "subject", subject, "email", email) return &domain.User{ ID: entity.ID, diff --git a/taco/internal/tfe/workspaces.go b/taco/internal/tfe/workspaces.go index 16d7537d7..d452cfe31 100644 --- a/taco/internal/tfe/workspaces.go +++ b/taco/internal/tfe/workspaces.go @@ -1052,10 +1052,10 @@ func (h *TfeHandler) ShowStateVersion(c echo.Context) error { return c.JSON(http.StatusOK, resp) } -// getOrgFromContext extracts org ID from Echo context or defaults to "default" +// getOrgFromContext extracts org UUID from Echo context func getOrgFromContext(c echo.Context) string { - // Try jwt_org (from JWT auth) - if jwtOrg := c.Get("jwt_org"); jwtOrg != nil { + // Try jwt_org_uuid (from JWT auth) + if jwtOrg := c.Get("jwt_org_uuid"); jwtOrg != nil { if orgStr, ok := jwtOrg.(string); ok && orgStr != "" { return orgStr } @@ -1068,6 +1068,5 @@ func getOrgFromContext(c echo.Context) string { } } - // Default for self-hosted - return "default" + return "" } diff --git a/taco/pkg/sdk/client.go b/taco/pkg/sdk/client.go index 8d9e62caf..b4d914140 100644 --- a/taco/pkg/sdk/client.go +++ b/taco/pkg/sdk/client.go @@ -40,6 +40,7 @@ func NewClientWithHTTPClient(baseURL string, httpClient *http.Client) *Client { // UnitMetadata represents unit metadata type UnitMetadata struct { ID string `json:"id"` + Name string `json:"name"` Size int64 `json:"size"` Updated time.Time `json:"updated"` Locked bool `json:"locked"` From 503f4b1ccf4a89576b0734018beed7c5c3ca0bfa Mon Sep 17 00:00:00 2001 From: Brian Reardon Date: Fri, 24 Oct 2025 07:55:47 -0700 Subject: [PATCH 2/2] add convenience endpoint, merge fix --- taco/internal/api/internal.go | 16 ++++- taco/internal/api/org_handler.go | 65 +++++++++++++++++++ taco/internal/auth/terraform.go | 4 +- taco/internal/domain/resource_resolver.go | 21 ++++-- taco/internal/middleware/org_context.go | 43 +++++++++--- taco/internal/middleware/webhook.go | 37 ++++++----- .../repositories/identifier_resolver.go | 13 ++-- taco/internal/repositories/org_repository.go | 5 ++ 8 files changed, 165 insertions(+), 39 deletions(-) diff --git a/taco/internal/api/internal.go b/taco/internal/api/internal.go index 8ef299c30..44bf400ea 100644 --- a/taco/internal/api/internal.go +++ b/taco/internal/api/internal.go @@ -36,9 +36,18 @@ func RegisterInternalRoutes(e *echo.Echo, deps Dependencies) { internal := e.Group("/internal/api") internal.Use(middleware.WebhookAuth()) - // Validate org UUID from webhook header and add to domain context - internal.Use(middleware.WebhookOrgUUIDMiddleware()) - log.Println("Org UUID validation middleware enabled for internal routes") + // Add org resolution middleware - resolves org UUID or external ID to UUID and adds to domain context + if deps.QueryStore != nil { + if db := repositories.GetDBFromQueryStore(deps.QueryStore); db != nil { + // Create identifier resolver (infrastructure layer) + identifierResolver := repositories.NewIdentifierResolver(db) + // Pass interface to middleware (clean architecture!) + internal.Use(middleware.ResolveOrgContextMiddleware(identifierResolver)) + log.Println("Org context resolution middleware enabled for internal routes (UUID and external org ID)") + } else { + log.Println("WARNING: QueryStore does not implement GetDB() *gorm.DB - org resolution disabled") + } + } // Organization and User management endpoints if orgRepo != nil && userRepo != nil { @@ -48,6 +57,7 @@ func RegisterInternalRoutes(e *echo.Echo, deps Dependencies) { // Organization endpoints internal.POST("/orgs", orgHandler.CreateOrganization) internal.POST("/orgs/sync", orgHandler.SyncExternalOrg) + internal.GET("/orgs/user", orgHandler.GetMyOrganizations) // Get orgs for user - no org context required internal.GET("/orgs/:orgId", orgHandler.GetOrganization) internal.GET("/orgs", orgHandler.ListOrganizations) diff --git a/taco/internal/api/org_handler.go b/taco/internal/api/org_handler.go index 8552a792a..be92a2ddf 100644 --- a/taco/internal/api/org_handler.go +++ b/taco/internal/api/org_handler.go @@ -355,6 +355,71 @@ func (h *OrgHandler) GetOrganization(c echo.Context) error { }) } +// GetMyOrganizations handles GET /internal/orgs/user +// Returns organizations for the current user (no org context required) +// Requires: X-User-ID and X-Email headers with webhook secret authentication +func (h *OrgHandler) GetMyOrganizations(c echo.Context) error { + ctx := c.Request().Context() + + // Get user context from webhook middleware + userID := c.Get("user_id") + if userID == nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "user context required", + }) + } + + userIDStr, ok := userID.(string) + if !ok || userIDStr == "" { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "invalid user context", + }) + } + + // Get all organizations + allOrgs, err := h.orgRepo.List(ctx) + if err != nil { + slog.Error("Failed to list organizations for user", "userID", userIDStr, "error", err) + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "failed to list organizations", + }) + } + + // Filter to orgs where user has roles (if RBAC is enabled) + var userOrgs []*domain.Organization + if h.rbacManager != nil { + for _, org := range allOrgs { + // Check if user has any roles in this org + orgCtx := domain.ContextWithOrg(ctx, org.ID) + hasAccess, _ := h.rbacManager.IsEnabled(orgCtx) + if hasAccess { + userOrgs = append(userOrgs, org) + } + } + } else { + // No RBAC - return all orgs + userOrgs = allOrgs + } + + // Convert to response format + response := make([]CreateOrgResponse, len(userOrgs)) + for i, org := range userOrgs { + response[i] = CreateOrgResponse{ + ID: org.ID, + Name: org.Name, + DisplayName: org.DisplayName, + ExternalOrgID: org.ExternalOrgID, + CreatedBy: org.CreatedBy, + CreatedAt: org.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "organizations": response, + "count": len(response), + }) +} + // ListOrganizations handles GET /internal/orgs func (h *OrgHandler) ListOrganizations(c echo.Context) error { ctx := c.Request().Context() diff --git a/taco/internal/auth/terraform.go b/taco/internal/auth/terraform.go index 25d4e08d1..df1cd8255 100644 --- a/taco/internal/auth/terraform.go +++ b/taco/internal/auth/terraform.go @@ -822,7 +822,9 @@ func (h *Handler) ensureUserHasOrg(ctx context.Context, subject, email string) ( orgName := fmt.Sprintf("user-%s", subject[:min(8, len(subject))]) orgDisplayName := fmt.Sprintf("%s's Organization", email) - org, err := h.orgRepo.Create(ctx, orgName, orgDisplayName, subject) + // Create org with orgID=orgName (the unique identifier) + // name=orgName (stored in DB), displayName (friendly name), no externalOrgID, createdBy=subject + org, err := h.orgRepo.Create(ctx, orgName, orgName, orgDisplayName, "", subject) if err != nil { return "", fmt.Errorf("failed to create org: %w", err) } diff --git a/taco/internal/domain/resource_resolver.go b/taco/internal/domain/resource_resolver.go index 399d1835f..a33bc1303 100644 --- a/taco/internal/domain/resource_resolver.go +++ b/taco/internal/domain/resource_resolver.go @@ -12,7 +12,10 @@ var ( ErrAmbiguousIdentifier = errors.New("identifier matches multiple resources") 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}$`) - absoluteNamePattern = regexp.MustCompile(`^org-([a-zA-Z0-9_-]+)/(.+)$`) + // Absolute name pattern: supports UUID, external org ID, or legacy name format + // Examples: "550e8400-e29b-41d4-a716-446655440000/unit-name", "auth0_org_123/unit-name", "org-acme/unit-name" (legacy) + absoluteNamePattern = regexp.MustCompile(`^([a-zA-Z0-9_-]+)/(.+)$`) + legacyOrgPattern = regexp.MustCompile(`^org-([a-zA-Z0-9_-]+)$`) ) type IdentifierType int @@ -34,7 +37,9 @@ type ParsedIdentifier struct { // Supports three formats: // - UUID: "a1b2c3d4-1234-5678-90ab-cdef12345678" // - Simple name: "dev" (resolved within current org context) -// - Absolute name: "org-acme/dev" (explicitly specifies org) +// - Absolute name: "/dev" (explicitly specifies org) +// Examples: "550e8400-e29b-41d4-a716-446655440000/dev", "auth0_org_123/dev" +// Legacy format "org-acme/dev" is also supported but deprecated (names are not unique) func ParseIdentifier(identifier string) (*ParsedIdentifier, error) { if identifier == "" { return nil, fmt.Errorf("%w: empty identifier", ErrInvalidIdentifier) @@ -48,10 +53,18 @@ func ParseIdentifier(identifier string) (*ParsedIdentifier, error) { } if matches := absoluteNamePattern.FindStringSubmatch(identifier); matches != nil { + orgIdentifier := matches[1] + resourceName := matches[2] + + // Strip "org-" prefix if present (legacy format) + if legacyMatches := legacyOrgPattern.FindStringSubmatch(orgIdentifier); legacyMatches != nil { + orgIdentifier = legacyMatches[1] + } + return &ParsedIdentifier{ Type: IdentifierTypeAbsoluteName, - OrgName: matches[1], - Name: matches[2], + OrgName: orgIdentifier, // This is now UUID, external ID, or legacy name + Name: resourceName, }, nil } diff --git a/taco/internal/middleware/org_context.go b/taco/internal/middleware/org_context.go index 14dc24eb0..a6c534700 100644 --- a/taco/internal/middleware/org_context.go +++ b/taco/internal/middleware/org_context.go @@ -27,24 +27,47 @@ func JWTOrgUUIDMiddleware() echo.MiddlewareFunc { } } -// WebhookOrgUUIDMiddleware extracts org UUID from webhook header and adds to domain context -// For internal routes (/internal/api/*) - expects UUID in X-Org-ID header -func WebhookOrgUUIDMiddleware() echo.MiddlewareFunc { +// ResolveOrgContextMiddleware resolves org identifier to UUID and adds to domain context +// For internal routes (/internal/api/*) - resolves X-Org-ID header (UUID or external org ID) +// Skips validation for endpoints that don't require an existing org (like creating/listing orgs) +func ResolveOrgContextMiddleware(resolver domain.IdentifierResolver) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - orgUUID, ok := c.Get("organization_id").(string) - if !ok || orgUUID == "" { - log.Printf("[WebhookOrgUUID] organization_id not found in context") + // Skip org resolution for endpoints that create/list orgs + path := c.Request().URL.Path + method := c.Request().Method + + // These endpoints don't require an existing org context + skipOrgResolution := (method == "POST" && path == "/internal/api/orgs") || + (method == "POST" && path == "/internal/api/orgs/sync") || + (method == "GET" && path == "/internal/api/orgs") || + (method == "GET" && path == "/internal/api/orgs/user") + + if skipOrgResolution { + log.Printf("[ResolveOrgContext] Skipping org resolution for %s %s", method, path) + return next(c) + } + + // Get org identifier from webhook auth middleware + orgIdentifier, ok := c.Get("organization_id").(string) + if !ok || orgIdentifier == "" { + log.Printf("[ResolveOrgContext] organization_id not found in context") return echo.NewHTTPError(400, "X-Org-ID header is required") } - if !domain.IsUUID(orgUUID) { - log.Printf("[WebhookOrgUUID] organization_id is not a UUID: %s", orgUUID) - return echo.NewHTTPError(400, "X-Org-ID must be a UUID") + // Resolve identifier to UUID (accepts UUID or external org ID, NOT names) + orgUUID, err := resolver.ResolveOrganization(c.Request().Context(), orgIdentifier) + if err != nil { + log.Printf("[ResolveOrgContext] Failed to resolve org identifier %q: %v", orgIdentifier, err) + return echo.NewHTTPError(400, map[string]string{ + "error": "Invalid organization identifier", + "details": err.Error(), + }) } - log.Printf("[WebhookOrgUUID] Found org UUID: %s", orgUUID) + log.Printf("[ResolveOrgContext] Resolved %q to UUID: %s", orgIdentifier, orgUUID) + // Add org UUID to domain context ctx := domain.ContextWithOrg(c.Request().Context(), orgUUID) c.SetRequest(c.Request().WithContext(ctx)) diff --git a/taco/internal/middleware/webhook.go b/taco/internal/middleware/webhook.go index 70329fdf4..14eba2264 100644 --- a/taco/internal/middleware/webhook.go +++ b/taco/internal/middleware/webhook.go @@ -54,27 +54,32 @@ func WebhookAuth() echo.MiddlewareFunc { }) } - userID := c.Request().Header.Get("X-User-ID") - email := c.Request().Header.Get("X-Email") - orgID := c.Request().Header.Get("X-Org-ID") + userID := c.Request().Header.Get("X-User-ID") + email := c.Request().Header.Get("X-Email") + orgID := c.Request().Header.Get("X-Org-ID") - // Skip org validation for create org endpoint - isCreateOrg := c.Request().Method == http.MethodPost && c.Path() == "/internal/api/orgs" + // Skip org validation for endpoints that don't require existing org + path := c.Request().URL.Path + method := c.Request().Method + skipOrgHeader := (method == http.MethodPost && path == "/internal/api/orgs") || + (method == http.MethodPost && path == "/internal/api/orgs/sync") || + (method == http.MethodGet && path == "/internal/api/orgs") || + (method == http.MethodGet && path == "/internal/api/orgs/user") - if userID == "" { + if userID == "" { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "X-User-ID header required", + }) + } + + // Require org ID for all requests except org creation/listing + if !skipOrgHeader { + if orgID == "" { return c.JSON(http.StatusBadRequest, map[string]string{ - "error": "X-User-ID header required", + "error": "X-Org-ID header required", }) } - - // Require org UUID for all requests except create org - if !isCreateOrg { - if orgID == "" { - return c.JSON(http.StatusBadRequest, map[string]string{ - "error": "X-Org-ID header required", - }) - } - } + } principal := rbac.Principal{ Subject: userID, diff --git a/taco/internal/repositories/identifier_resolver.go b/taco/internal/repositories/identifier_resolver.go index 18f5d1115..3b2ce1a9f 100644 --- a/taco/internal/repositories/identifier_resolver.go +++ b/taco/internal/repositories/identifier_resolver.go @@ -3,7 +3,6 @@ package repositories import ( "context" "fmt" - "strings" "github.com/diggerhq/digger/opentaco/internal/domain" "gorm.io/gorm" @@ -39,9 +38,9 @@ func (r *gormIdentifierResolver) ResolveOrganization(ctx context.Context, identi return parsed.UUID, nil } - - // If not found by name, try external org ID - // This handles cases where someone passes an external ID directly + // Try to resolve by external org ID + // Names are NOT unique, so we only support UUID or external org ID + var result struct{ ID string } err = r.db.WithContext(ctx). Table("organizations"). Select("id"). @@ -52,7 +51,11 @@ func (r *gormIdentifierResolver) ResolveOrganization(ctx context.Context, identi return result.ID, nil } - return "", fmt.Errorf("organization not found: %s", parsed.Name) + if err == gorm.ErrRecordNotFound { + return "", fmt.Errorf("organization not found with external ID: %s (names are not unique and cannot be resolved)", parsed.Name) + } + + return "", fmt.Errorf("failed to resolve organization: %w", err) } // ResolveUnit resolves unit identifier to UUID within an organization diff --git a/taco/internal/repositories/org_repository.go b/taco/internal/repositories/org_repository.go index c54d5a979..26bcef059 100644 --- a/taco/internal/repositories/org_repository.go +++ b/taco/internal/repositories/org_repository.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log/slog" + "strings" "time" "github.com/diggerhq/digger/opentaco/internal/domain" @@ -19,6 +20,10 @@ type orgRepository struct { db *gorm.DB } +const ( + queryOrgByName = "name = ?" +) + // Helper function to safely get string value from pointer func getStringValue(ptr *string) string { if ptr == nil {