From 3068edd8bae1cde65da4bbe9b65b07cf672becfc Mon Sep 17 00:00:00 2001 From: jeremy Date: Fri, 17 Apr 2026 15:46:37 -0400 Subject: [PATCH 1/4] feat: update crate and blueprint sync logic to use pathPrefix, enhance error reporting, and add backward compatibility for construct data --- .../core/catalog/adapters/registry/github.go | 119 +++++++++++++++--- 1 file changed, 101 insertions(+), 18 deletions(-) diff --git a/internal/core/catalog/adapters/registry/github.go b/internal/core/catalog/adapters/registry/github.go index f400826..4c0a61d 100644 --- a/internal/core/catalog/adapters/registry/github.go +++ b/internal/core/catalog/adapters/registry/github.go @@ -61,23 +61,24 @@ func (r *CrateRegistry) Sync(ctx context.Context, store ports.CatalogRepository) for _, entry := range index.allEntries() { cat := entry.Category + pathPrefix := entry.PathPrefix ref := entry.Ref // 2. Fetch and upsert crate metadata - crateData, err := r.fetch(ctx, fmt.Sprintf("%s/%s/crate.json", cat, ref.ID)) + crateData, err := r.fetch(ctx, fmt.Sprintf("%s/%s/crate.json", pathPrefix, ref.ID)) if err != nil { - syncErrors = append(syncErrors, fmt.Sprintf("crate %s/%s: %v", cat, ref.ID, err)) + syncErrors = append(syncErrors, fmt.Sprintf("crate %s/%s: %v", pathPrefix, ref.ID, err)) continue } var wc wireCrate if err := json.Unmarshal(crateData, &wc); err != nil { - syncErrors = append(syncErrors, fmt.Sprintf("crate %s/%s parse: %v", cat, ref.ID, err)) + syncErrors = append(syncErrors, fmt.Sprintf("crate %s/%s parse: %v", pathPrefix, ref.ID, err)) continue } if err := store.UpsertCrate(ctx, wc.toDomain(cat)); err != nil { - syncErrors = append(syncErrors, fmt.Sprintf("crate %s/%s upsert: %v", cat, ref.ID, err)) + syncErrors = append(syncErrors, fmt.Sprintf("crate %s/%s upsert: %v", pathPrefix, ref.ID, err)) continue } @@ -86,26 +87,37 @@ func (r *CrateRegistry) Sync(ctx context.Context, store ports.CatalogRepository) // fields (image, env, ports, outputs, runtime_hints overrides). // A Construct is derived from it using crate-level runtime_hints as defaults. for _, version := range ref.Versions { - bpData, err := r.fetch(ctx, fmt.Sprintf("%s/%s/%s/blueprint.json", cat, ref.ID, version)) + bpData, err := r.fetch(ctx, fmt.Sprintf("%s/%s/%s/blueprint.json", pathPrefix, ref.ID, version)) if err != nil { - syncErrors = append(syncErrors, fmt.Sprintf("blueprint %s/%s/%s: %v", cat, ref.ID, version, err)) + syncErrors = append(syncErrors, fmt.Sprintf("blueprint %s/%s/%s: %v", pathPrefix, ref.ID, version, err)) continue } var wb wireBlueprint if err := json.Unmarshal(bpData, &wb); err != nil { - syncErrors = append(syncErrors, fmt.Sprintf("blueprint %s/%s/%s parse: %v", cat, ref.ID, version, err)) + syncErrors = append(syncErrors, fmt.Sprintf("blueprint %s/%s/%s parse: %v", pathPrefix, ref.ID, version, err)) continue } + // Backward compatibility: older registry entries keep deployment runtime + // fields in construct.json. Merge them when blueprint.json omits them. + if constructData, err := r.fetch(ctx, fmt.Sprintf("%s/%s/%s/construct.json", pathPrefix, ref.ID, version)); err == nil { + var wc wireConstruct + if err := json.Unmarshal(constructData, &wc); err != nil { + syncErrors = append(syncErrors, fmt.Sprintf("construct %s/%s/%s parse: %v", pathPrefix, ref.ID, version, err)) + } else { + wb.applyConstructFallback(wc) + } + } + // Optionally fetch entrypoint.sh — not all variants need a startup script. var startupScript string - if scriptData, err := r.fetch(ctx, fmt.Sprintf("%s/%s/%s/entrypoint.sh", cat, ref.ID, version)); err == nil { + if scriptData, err := r.fetch(ctx, fmt.Sprintf("%s/%s/%s/entrypoint.sh", pathPrefix, ref.ID, version)); err == nil { startupScript = string(scriptData) } if err := store.UpsertBlueprint(ctx, wb.toDomain(wc.RuntimeHints, startupScript)); err != nil { - syncErrors = append(syncErrors, fmt.Sprintf("blueprint %s/%s/%s upsert: %v", cat, ref.ID, version, err)) + syncErrors = append(syncErrors, fmt.Sprintf("blueprint %s/%s/%s upsert: %v", pathPrefix, ref.ID, version, err)) } } } @@ -162,23 +174,26 @@ type crateIndex struct { Storage []crateRef `json:"storage"` Web []crateRef `json:"web"` Apps []crateRef `json:"apps"` + Crates []crateRef `json:"crates"` } func (idx crateIndex) allEntries() []categoryEntry { var out []categoryEntry - for _, ref := range idx.Games { out = append(out, categoryEntry{"games", ref}) } - for _, ref := range idx.Databases { out = append(out, categoryEntry{"databases", ref}) } - for _, ref := range idx.Cache { out = append(out, categoryEntry{"cache", ref}) } - for _, ref := range idx.Storage { out = append(out, categoryEntry{"storage", ref}) } - for _, ref := range idx.Web { out = append(out, categoryEntry{"web", ref}) } - for _, ref := range idx.Apps { out = append(out, categoryEntry{"apps", ref}) } + for _, ref := range idx.Games { out = append(out, categoryEntry{"games", "games", ref}) } + for _, ref := range idx.Databases { out = append(out, categoryEntry{"databases", "databases", ref}) } + for _, ref := range idx.Cache { out = append(out, categoryEntry{"cache", "cache", ref}) } + for _, ref := range idx.Storage { out = append(out, categoryEntry{"storage", "storage", ref}) } + for _, ref := range idx.Web { out = append(out, categoryEntry{"web", "web", ref}) } + for _, ref := range idx.Apps { out = append(out, categoryEntry{"apps", "apps", ref}) } + for _, ref := range idx.Crates { out = append(out, categoryEntry{"", "crates", ref}) } return out } // categoryEntry pairs a category name with its crate reference from index.json. type categoryEntry struct { - Category string - Ref crateRef + Category string + PathPrefix string + Ref crateRef } // crateRef is an entry in index.json listing a crate's version folder names. @@ -193,6 +208,7 @@ type crateRef struct { type wireCrate struct { ID string `json:"id"` Name string `json:"name"` + Category string `json:"category"` Description string `json:"description"` Logo string `json:"logo"` Tags []string `json:"tags"` @@ -201,10 +217,18 @@ type wireCrate struct { } func (w wireCrate) toDomain(category string) *domain.Crate { + resolvedCategory := strings.TrimSpace(category) + if resolvedCategory == "" { + resolvedCategory = strings.TrimSpace(w.Category) + } + if resolvedCategory == "" { + resolvedCategory = "games" + } + return &domain.Crate{ ID: w.ID, Name: w.Name, - Category: category, + Category: resolvedCategory, Description: w.Description, Logo: w.Logo, Tags: w.Tags, @@ -263,6 +287,65 @@ type wireBlueprint struct { Extensions map[string]wireBlueprintExtension `json:"extensions"` } +type wireConstruct struct { + ID string `json:"id"` + Crate string `json:"crate"` + Blueprint string `json:"blueprint"` + Image string `json:"image"` + Version string `json:"version"` + Env map[string]string `json:"env"` + Ports []domain.Port `json:"ports"` + RuntimeHints domain.RuntimeHints `json:"runtime_hints"` + Outputs []domain.Output `json:"outputs"` +} + +func (w *wireBlueprint) applyConstructFallback(construct wireConstruct) { + if w.Image == "" { + w.Image = construct.Image + } + + if len(w.Env) == 0 && len(construct.Env) > 0 { + w.Env = construct.Env + } + + if len(w.Ports) == 0 && len(construct.Ports) > 0 { + w.Ports = construct.Ports + } + + if len(w.Outputs) == 0 && len(construct.Outputs) > 0 { + w.Outputs = construct.Outputs + } + + if w.RuntimeHints.KubernetesStrategy == nil { + v := construct.RuntimeHints.KubernetesStrategy + w.RuntimeHints.KubernetesStrategy = &v + } + if w.RuntimeHints.ExposeUDP == nil { + v := construct.RuntimeHints.ExposeUDP + w.RuntimeHints.ExposeUDP = &v + } + if w.RuntimeHints.PersistentStorage == nil { + v := construct.RuntimeHints.PersistentStorage + w.RuntimeHints.PersistentStorage = &v + } + if w.RuntimeHints.StoragePath == nil { + v := construct.RuntimeHints.StoragePath + w.RuntimeHints.StoragePath = &v + } + if w.RuntimeHints.StorageGB == nil { + v := construct.RuntimeHints.StorageGB + w.RuntimeHints.StorageGB = &v + } + if w.RuntimeHints.HealthCheckPath == nil { + v := construct.RuntimeHints.HealthCheckPath + w.RuntimeHints.HealthCheckPath = &v + } + if w.RuntimeHints.HealthCheckPort == nil { + v := construct.RuntimeHints.HealthCheckPort + w.RuntimeHints.HealthCheckPort = &v + } +} + func (w wireBlueprint) toDomain(crateHints domain.RuntimeHints, startupScript string) *domain.Blueprint { var bpExts map[string]domain.BlueprintExtension if len(w.Extensions) > 0 { From 16e2fdcd83239e28788b5340bf591557bb4e6a54 Mon Sep 17 00:00:00 2001 From: jeremy Date: Fri, 17 Apr 2026 17:13:32 -0400 Subject: [PATCH 2/4] initial structure --- internal/bootstrap/container.go | 8 +- .../organizations/adapters/http/handler.go | 627 +++++++++++++++++- .../adapters/persistence/store.go | 346 ++++++++++ .../commands/create_organization.go | 10 + .../core/organizations/domain/organization.go | 31 + .../core/organizations/ports/repository.go | 23 +- .../core/projects/adapters/http/handler.go | 68 +- .../core/workloads/adapters/http/handler.go | 39 +- .../database/migrations/008_org_members.sql | 35 + 9 files changed, 1134 insertions(+), 53 deletions(-) create mode 100644 internal/core/organizations/adapters/persistence/store.go create mode 100644 internal/database/migrations/008_org_members.sql diff --git a/internal/bootstrap/container.go b/internal/bootstrap/container.go index 5547c92..a299cc2 100644 --- a/internal/bootstrap/container.go +++ b/internal/bootstrap/container.go @@ -27,6 +27,7 @@ import ( nodespersistence "github.com/kleffio/platform/internal/core/nodes/adapters/persistence" nodesapp "github.com/kleffio/platform/internal/core/nodes/application" organizationshttp "github.com/kleffio/platform/internal/core/organizations/adapters/http" + organizationspersistence "github.com/kleffio/platform/internal/core/organizations/adapters/persistence" pluginhttp "github.com/kleffio/platform/internal/core/plugins/adapters/http" pluginpersistence "github.com/kleffio/platform/internal/core/plugins/adapters/persistence" pluginregistry "github.com/kleffio/platform/internal/core/plugins/adapters/registry" @@ -129,6 +130,7 @@ func NewContainer(cfg *Config, logger *slog.Logger) (*Container, error) { nodeStore := nodespersistence.NewPostgresNodeStore(db) nodeVerifier := nodesapp.NewTokenVerifier(nodeStore) + orgStore := organizationspersistence.NewPostgresOrgStore(db) projectsStore := projectspersistence.NewPostgresProjectStore(db) workloadsStore := workloadspersistence.NewPostgresStore(db) @@ -156,10 +158,10 @@ func NewContainer(cfg *Config, logger *slog.Logger) (*Container, error) { AuthHandler: pluginhttp.NewAuthHandler(pluginMgr, logger), SetupHandler: pluginhttp.NewSetupHandler(pluginMgr, catalogRegistry, logger), CatalogHandler: cataloghttp.NewHandler(catalogStore, logger), - OrganizationsHandler: organizationshttp.NewHandler(logger), + OrganizationsHandler: organizationshttp.NewHandler(orgStore, logger), DeploymentsHandler: deploymentshttp.NewHandler(createDeployment, serverAction, deploymentStore, cfg.SecretKey, logger), - ProjectsHandler: projectshttp.NewHandler(projectsStore, logger), - WorkloadsHandler: workloadshttp.NewHandler(projectsStore, workloadsStore, provisionHandler, workloadAction, bus, logger), + ProjectsHandler: projectshttp.NewHandler(projectsStore, orgStore, logger), + WorkloadsHandler: workloadshttp.NewHandler(projectsStore, orgStore, workloadsStore, provisionHandler, workloadAction, bus, logger), NodesHandler: nodeshttp.NewHandler(nodeStore, logger), BillingHandler: billinghttp.NewHandler(logger), UsageHandler: usagehttp.NewHandler(logger), diff --git a/internal/core/organizations/adapters/http/handler.go b/internal/core/organizations/adapters/http/handler.go index 4d956e1..57417f7 100644 --- a/internal/core/organizations/adapters/http/handler.go +++ b/internal/core/organizations/adapters/http/handler.go @@ -1,21 +1,34 @@ package http import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" "log/slog" "net/http" + "strings" + "time" "github.com/go-chi/chi/v5" + "github.com/kleffio/platform/internal/core/organizations/adapters/persistence" + "github.com/kleffio/platform/internal/core/organizations/domain" + "github.com/kleffio/platform/internal/core/organizations/ports" + "github.com/kleffio/platform/internal/shared/ids" + "github.com/kleffio/platform/internal/shared/middleware" ) const basePath = "/api/v1/organizations" // Handler groups all HTTP endpoints for the organizations module. type Handler struct { + repo ports.OrganizationRepository logger *slog.Logger } -func NewHandler(logger *slog.Logger) *Handler { - return &Handler{logger: logger} +func NewHandler(repo ports.OrganizationRepository, logger *slog.Logger) *Handler { + return &Handler{repo: repo, logger: logger} } // RegisterRoutes attaches all organizations routes to the provided router. @@ -29,20 +42,604 @@ func (h *Handler) RegisterRoutes(r chi.Router) { // Members sub-resource r.Get(basePath+"/{id}/members", h.listMembers) r.Post(basePath+"/{id}/members", h.addMember) + r.Patch(basePath+"/{id}/members/{userId}", h.updateMemberRole) r.Delete(basePath+"/{id}/members/{userId}", h.removeMember) + + // Invites sub-resource + r.Get(basePath+"/{id}/invites", h.listInvites) + r.Post(basePath+"/{id}/invites", h.createInvite) + r.Delete(basePath+"/{id}/invites/{inviteId}", h.revokeInvite) + + // Public invite resolution + accept + r.Get("/api/v1/invites/{token}", h.resolveInvite) + r.Post("/api/v1/invites/{token}/accept", h.acceptInvite) +} + +// ── List orgs the caller belongs to ────────────────────────────────────────── + +func (h *Handler) list(w http.ResponseWriter, r *http.Request) { + claims, ok := middleware.ClaimsFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusUnauthorized, errBody("unauthorized")) + return + } + + orgs, err := h.repo.ListByUserID(r.Context(), claims.Subject) + if err != nil { + h.logger.Error("list orgs", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to list organizations")) + return + } + if orgs == nil { + orgs = []*domain.Organization{} + } + writeJSON(w, http.StatusOK, map[string]any{"organizations": orgs}) +} + +// ── Create org ──────────────────────────────────────────────────────────────── + +func (h *Handler) create(w http.ResponseWriter, r *http.Request) { + claims, ok := middleware.ClaimsFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusUnauthorized, errBody("unauthorized")) + return + } + + var req struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errBody("invalid json body")) + return + } + if strings.TrimSpace(req.Name) == "" { + writeJSON(w, http.StatusBadRequest, errBody("name is required")) + return + } + + now := time.Now().UTC() + org := &domain.Organization{ + ID: ids.New(), + Name: strings.TrimSpace(req.Name), + CreatedAt: now, + UpdatedAt: now, + } + + if err := h.repo.Save(r.Context(), org); err != nil { + h.logger.Error("create organization", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to create organization")) + return + } + + // Caller is automatically the first owner. + member := &domain.Member{ + OrgID: org.ID, + UserID: claims.Subject, + Email: claims.Email, + DisplayName: claims.Username, + Role: domain.RoleOwner, + CreatedAt: now, + } + if err := h.repo.AddMember(r.Context(), member); err != nil { + h.logger.Error("add owner after create", "error", err) + // Non-fatal: org was created, membership may be retried. + } + + writeJSON(w, http.StatusCreated, org) +} + +// ── Get org ─────────────────────────────────────────────────────────────────── + +func (h *Handler) get(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + org, err := h.authorizedOrg(r, id, "") + if err != nil { + writeOrgError(w, err) + return + } + writeJSON(w, http.StatusOK, org) +} + +// ── Update org name ─────────────────────────────────────────────────────────── + +func (h *Handler) update(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if _, err := h.authorizedOrg(r, id, domain.RoleAdmin); err != nil { + writeOrgError(w, err) + return + } + + var req struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errBody("invalid json body")) + return + } + if strings.TrimSpace(req.Name) == "" { + writeJSON(w, http.StatusBadRequest, errBody("name is required")) + return + } + + org := &domain.Organization{ + ID: id, + Name: strings.TrimSpace(req.Name), + UpdatedAt: time.Now().UTC(), + } + if err := h.repo.Update(r.Context(), org); err != nil { + h.logger.Error("update organization", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to update organization")) + return + } + writeJSON(w, http.StatusOK, org) } -func notImplemented(w http.ResponseWriter) { +// ── Delete org ──────────────────────────────────────────────────────────────── + +func (h *Handler) delete(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if _, err := h.authorizedOrg(r, id, domain.RoleOwner); err != nil { + writeOrgError(w, err) + return + } + + if err := h.repo.Delete(r.Context(), id); err != nil { + h.logger.Error("delete organization", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to delete organization")) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ── Members ─────────────────────────────────────────────────────────────────── + +func (h *Handler) listMembers(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if _, err := h.authorizedOrg(r, id, ""); err != nil { + writeOrgError(w, err) + return + } + + members, err := h.repo.ListMembers(r.Context(), id) + if err != nil { + h.logger.Error("list members", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to list members")) + return + } + if members == nil { + members = []*domain.Member{} + } + writeJSON(w, http.StatusOK, map[string]any{"members": members}) +} + +func (h *Handler) addMember(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if _, err := h.authorizedOrg(r, id, domain.RoleAdmin); err != nil { + writeOrgError(w, err) + return + } + + var req struct { + UserID string `json:"user_id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errBody("invalid json body")) + return + } + if req.UserID == "" { + writeJSON(w, http.StatusBadRequest, errBody("user_id is required")) + return + } + role := normalizeRole(req.Role) + + member := &domain.Member{ + OrgID: id, + UserID: req.UserID, + Email: req.Email, + DisplayName: req.DisplayName, + Role: role, + CreatedAt: time.Now().UTC(), + } + if err := h.repo.AddMember(r.Context(), member); err != nil { + h.logger.Error("add member", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to add member")) + return + } + writeJSON(w, http.StatusCreated, member) +} + +func (h *Handler) updateMemberRole(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + userID := chi.URLParam(r, "userId") + + if _, err := h.authorizedOrg(r, id, domain.RoleAdmin); err != nil { + writeOrgError(w, err) + return + } + + var req struct { + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errBody("invalid json body")) + return + } + role := normalizeRole(req.Role) + + if err := h.repo.UpdateMemberRole(r.Context(), id, userID, role); err != nil { + h.logger.Error("update member role", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to update role")) + return + } + writeJSON(w, http.StatusOK, map[string]string{"role": role}) +} + +func (h *Handler) removeMember(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + userID := chi.URLParam(r, "userId") + + claims, ok := middleware.ClaimsFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusUnauthorized, errBody("unauthorized")) + return + } + + // Members may remove themselves; removing others requires admin/owner. + if claims.Subject != userID { + if _, err := h.authorizedOrg(r, id, domain.RoleAdmin); err != nil { + writeOrgError(w, err) + return + } + } else { + // Self-removal: still need to be a member. + if _, err := h.authorizedOrg(r, id, ""); err != nil { + writeOrgError(w, err) + return + } + } + + // Prevent removing the last owner. + target, err := h.repo.GetMember(r.Context(), id, userID) + if err != nil { + writeJSON(w, http.StatusNotFound, errBody("member not found")) + return + } + if target.Role == domain.RoleOwner { + count, err := h.repo.CountOwners(r.Context(), id) + if err != nil || count <= 1 { + writeJSON(w, http.StatusConflict, errBody("cannot remove the last owner")) + return + } + } + + if err := h.repo.RemoveMember(r.Context(), id, userID); err != nil { + h.logger.Error("remove member", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to remove member")) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ── Invites ─────────────────────────────────────────────────────────────────── + +func (h *Handler) listInvites(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if _, err := h.authorizedOrg(r, id, domain.RoleAdmin); err != nil { + writeOrgError(w, err) + return + } + + invites, err := h.repo.ListInvites(r.Context(), id) + if err != nil { + h.logger.Error("list invites", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to list invites")) + return + } + if invites == nil { + invites = []*domain.Invite{} + } + // Strip token hashes from the list response. + type safeInvite struct { + ID string `json:"id"` + OrgID string `json:"org_id"` + InvitedEmail string `json:"invited_email"` + Role string `json:"role"` + InvitedBy string `json:"invited_by"` + ExpiresAt time.Time `json:"expires_at"` + AcceptedAt *time.Time `json:"accepted_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + } + out := make([]safeInvite, len(invites)) + for i, inv := range invites { + out[i] = safeInvite{ + ID: inv.ID, + OrgID: inv.OrgID, + InvitedEmail: inv.InvitedEmail, + Role: inv.Role, + InvitedBy: inv.InvitedBy, + ExpiresAt: inv.ExpiresAt, + AcceptedAt: inv.AcceptedAt, + CreatedAt: inv.CreatedAt, + } + } + writeJSON(w, http.StatusOK, map[string]any{"invites": out}) +} + +func (h *Handler) createInvite(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + claims, ok := middleware.ClaimsFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusUnauthorized, errBody("unauthorized")) + return + } + + if _, err := h.authorizedOrg(r, id, domain.RoleAdmin); err != nil { + writeOrgError(w, err) + return + } + + var req struct { + Email string `json:"email"` + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errBody("invalid json body")) + return + } + if strings.TrimSpace(req.Email) == "" { + writeJSON(w, http.StatusBadRequest, errBody("email is required")) + return + } + + token, err := generateToken() + if err != nil { + h.logger.Error("generate invite token", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to generate invite")) + return + } + + now := time.Now().UTC() + inv := &domain.Invite{ + ID: ids.New(), + OrgID: id, + InvitedEmail: strings.ToLower(strings.TrimSpace(req.Email)), + Role: normalizeRole(req.Role), + Token: token, + TokenHash: persistence.HashToken(token), + InvitedBy: claims.Subject, + ExpiresAt: now.Add(7 * 24 * time.Hour), + CreatedAt: now, + } + + if err := h.repo.CreateInvite(r.Context(), inv); err != nil { + h.logger.Error("create invite", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to create invite")) + return + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "id": inv.ID, + "org_id": inv.OrgID, + "invited_email": inv.InvitedEmail, + "role": inv.Role, + "token": inv.Token, // raw token returned once; client builds the accept URL + "expires_at": inv.ExpiresAt, + "created_at": inv.CreatedAt, + }) +} + +func (h *Handler) revokeInvite(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + inviteID := chi.URLParam(r, "inviteId") + + if _, err := h.authorizedOrg(r, id, domain.RoleAdmin); err != nil { + writeOrgError(w, err) + return + } + + if err := h.repo.RevokeInvite(r.Context(), inviteID); err != nil { + h.logger.Error("revoke invite", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to revoke invite")) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ── Public invite resolution ────────────────────────────────────────────────── + +func (h *Handler) resolveInvite(w http.ResponseWriter, r *http.Request) { + token := chi.URLParam(r, "token") + tokenHash := persistence.HashToken(token) + + inv, err := h.repo.FindInviteByToken(r.Context(), tokenHash) + if err == sql.ErrNoRows || err != nil { + writeJSON(w, http.StatusNotFound, errBody("invite not found or expired")) + return + } + if inv.AcceptedAt != nil { + writeJSON(w, http.StatusGone, errBody("invite already accepted")) + return + } + if time.Now().After(inv.ExpiresAt) { + writeJSON(w, http.StatusGone, errBody("invite has expired")) + return + } + + org, err := h.repo.FindByID(r.Context(), inv.OrgID) + if err != nil { + writeJSON(w, http.StatusInternalServerError, errBody("failed to load organization")) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "id": inv.ID, + "org_id": inv.OrgID, + "org_name": org.Name, + "invited_email": inv.InvitedEmail, + "role": inv.Role, + "invited_by": inv.InvitedBy, + "expires_at": inv.ExpiresAt, + }) +} + +func (h *Handler) acceptInvite(w http.ResponseWriter, r *http.Request) { + token := chi.URLParam(r, "token") + tokenHash := persistence.HashToken(token) + + claims, ok := middleware.ClaimsFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusUnauthorized, errBody("unauthorized")) + return + } + + inv, err := h.repo.FindInviteByToken(r.Context(), tokenHash) + if err == sql.ErrNoRows || err != nil { + writeJSON(w, http.StatusNotFound, errBody("invite not found or expired")) + return + } + if inv.AcceptedAt != nil { + writeJSON(w, http.StatusGone, errBody("invite already accepted")) + return + } + if time.Now().After(inv.ExpiresAt) { + writeJSON(w, http.StatusGone, errBody("invite has expired")) + return + } + + if err := h.repo.AcceptInvite(r.Context(), inv.ID, claims.Subject, claims.Email, claims.Username); err != nil { + h.logger.Error("accept invite", "error", err) + writeJSON(w, http.StatusInternalServerError, errBody("failed to accept invite")) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"org_id": inv.OrgID}) +} + +// ── Access guard ────────────────────────────────────────────────────────────── + +// authorizedOrg loads the org and verifies the caller is a member. +// If minRole is non-empty, it also enforces the minimum role (owner > admin > member). +// +// When the caller has no membership row for their personal org (org-), +// we bootstrap it automatically so first-time access works without a separate +// project-list round-trip. +func (h *Handler) authorizedOrg(r *http.Request, orgID, minRole string) (*domain.Organization, error) { + claims, ok := middleware.ClaimsFromContext(r.Context()) + if !ok { + return nil, fmt.Errorf("unauthorized") + } + + org, err := h.repo.FindByID(r.Context(), orgID) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("not found") + } + if err != nil { + return nil, fmt.Errorf("internal") + } + + member, err := h.repo.GetMember(r.Context(), orgID, claims.Subject) + if err == sql.ErrNoRows { + // Bootstrap the personal org membership row if this is the caller's own org. + if personalOrgID(claims.Subject) == orgID { + orgName := "My Organization" + if claims.Username != "" { + orgName = claims.Username + "'s Organization" + } + if bootstrapErr := h.repo.EnsureOrgWithOwner(r.Context(), orgID, orgName, + claims.Subject, claims.Email, claims.Username); bootstrapErr != nil { + h.logger.Error("bootstrap org membership", "error", bootstrapErr) + return nil, fmt.Errorf("internal") + } + member, err = h.repo.GetMember(r.Context(), orgID, claims.Subject) + if err != nil { + return nil, fmt.Errorf("internal") + } + } else { + return nil, fmt.Errorf("forbidden") + } + } else if err != nil { + return nil, fmt.Errorf("internal") + } + + if minRole != "" && !roleAtLeast(member.Role, minRole) { + return nil, fmt.Errorf("forbidden") + } + return org, nil +} + +// personalOrgID returns the personal org ID for a given JWT subject, +// matching the derivation used in the projects handler. +func personalOrgID(subject string) string { + s := strings.ToLower(strings.TrimSpace(subject)) + s = strings.ReplaceAll(s, "_", "-") + s = strings.ReplaceAll(s, " ", "-") + // Keep only lowercase alphanum and hyphens. + var b strings.Builder + for _, c := range s { + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' { + b.WriteRune(c) + } + } + slug := strings.Trim(b.String(), "-") + if len(slug) > 40 { + slug = slug[:40] + } + return "org-" + slug +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +func roleAtLeast(have, need string) bool { + rank := map[string]int{ + domain.RoleMember: 1, + domain.RoleAdmin: 2, + domain.RoleOwner: 3, + } + return rank[have] >= rank[need] +} + +func normalizeRole(r string) string { + switch strings.ToLower(strings.TrimSpace(r)) { + case domain.RoleOwner: + return domain.RoleOwner + case domain.RoleAdmin: + return domain.RoleAdmin + default: + return domain.RoleMember + } +} + +func generateToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func writeOrgError(w http.ResponseWriter, err error) { + msg := err.Error() + switch msg { + case "unauthorized": + writeJSON(w, http.StatusUnauthorized, errBody("unauthorized")) + case "forbidden": + writeJSON(w, http.StatusForbidden, errBody("forbidden")) + case "not found": + writeJSON(w, http.StatusNotFound, errBody("organization not found")) + default: + writeJSON(w, http.StatusInternalServerError, errBody("internal error")) + } +} + +func errBody(msg string) map[string]string { + return map[string]string{"error": msg} +} + +func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusNotImplemented) - _, _ = w.Write([]byte(`{"error":"not implemented"}`)) -} - -func (h *Handler) list(w http.ResponseWriter, _ *http.Request) { notImplemented(w) } -func (h *Handler) create(w http.ResponseWriter, _ *http.Request) { notImplemented(w) } -func (h *Handler) get(w http.ResponseWriter, _ *http.Request) { notImplemented(w) } -func (h *Handler) update(w http.ResponseWriter, _ *http.Request) { notImplemented(w) } -func (h *Handler) delete(w http.ResponseWriter, _ *http.Request) { notImplemented(w) } -func (h *Handler) listMembers(w http.ResponseWriter, _ *http.Request) { notImplemented(w) } -func (h *Handler) addMember(w http.ResponseWriter, _ *http.Request) { notImplemented(w) } -func (h *Handler) removeMember(w http.ResponseWriter, _ *http.Request) { notImplemented(w) } + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} diff --git a/internal/core/organizations/adapters/persistence/store.go b/internal/core/organizations/adapters/persistence/store.go new file mode 100644 index 0000000..d9a958d --- /dev/null +++ b/internal/core/organizations/adapters/persistence/store.go @@ -0,0 +1,346 @@ +package persistence + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "fmt" + "time" + + "github.com/kleffio/platform/internal/core/organizations/domain" + "github.com/kleffio/platform/internal/core/organizations/ports" +) + +type PostgresOrgStore struct { + db *sql.DB +} + +func NewPostgresOrgStore(db *sql.DB) ports.OrganizationRepository { + return &PostgresOrgStore{db: db} +} + +// ── Organizations ───────────────────────────────────────────────────────────── + +func (s *PostgresOrgStore) FindByID(ctx context.Context, id string) (*domain.Organization, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT id, name, COALESCE(slug, ''), created_at, updated_at + FROM organizations WHERE id = $1`, id) + return scanOrg(row) +} + +func (s *PostgresOrgStore) FindBySlug(ctx context.Context, slug string) (*domain.Organization, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT id, name, COALESCE(slug, ''), created_at, updated_at + FROM organizations WHERE slug = $1`, slug) + return scanOrg(row) +} + +func (s *PostgresOrgStore) Save(ctx context.Context, org *domain.Organization) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO organizations (id, name, created_at, updated_at) + VALUES ($1, $2, $3, $4)`, + org.ID, org.Name, org.CreatedAt, org.UpdatedAt) + if err != nil { + return fmt.Errorf("save organization: %w", err) + } + return nil +} + +func (s *PostgresOrgStore) Update(ctx context.Context, org *domain.Organization) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE organizations SET name = $1, updated_at = $2 WHERE id = $3`, + org.Name, org.UpdatedAt, org.ID) + if err != nil { + return fmt.Errorf("update organization: %w", err) + } + return nil +} + +func (s *PostgresOrgStore) Delete(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM organizations WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("delete organization: %w", err) + } + return nil +} + +// ── Membership ──────────────────────────────────────────────────────────────── + +func (s *PostgresOrgStore) ListByUserID(ctx context.Context, userID string) ([]*domain.Organization, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT o.id, o.name, COALESCE(o.slug, ''), o.created_at, o.updated_at + FROM organizations o + JOIN organization_members m ON m.org_id = o.id + WHERE m.user_id = $1 + ORDER BY o.created_at ASC`, userID) + if err != nil { + return nil, fmt.Errorf("list orgs by user: %w", err) + } + defer rows.Close() + return scanOrgs(rows) +} + +func (s *PostgresOrgStore) ListMembers(ctx context.Context, orgID string) ([]*domain.Member, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT org_id, user_id, email, display_name, role, created_at + FROM organization_members + WHERE org_id = $1 + ORDER BY created_at ASC`, orgID) + if err != nil { + return nil, fmt.Errorf("list members: %w", err) + } + defer rows.Close() + return scanMembers(rows) +} + +func (s *PostgresOrgStore) GetMember(ctx context.Context, orgID, userID string) (*domain.Member, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT org_id, user_id, email, display_name, role, created_at + FROM organization_members + WHERE org_id = $1 AND user_id = $2`, orgID, userID) + return scanMember(row) +} + +func (s *PostgresOrgStore) AddMember(ctx context.Context, m *domain.Member) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO organization_members (org_id, user_id, email, display_name, role, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (org_id, user_id) DO NOTHING`, + m.OrgID, m.UserID, m.Email, m.DisplayName, m.Role, m.CreatedAt) + if err != nil { + return fmt.Errorf("add member: %w", err) + } + return nil +} + +func (s *PostgresOrgStore) UpdateMemberRole(ctx context.Context, orgID, userID, role string) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE organization_members SET role = $1 + WHERE org_id = $2 AND user_id = $3`, + role, orgID, userID) + if err != nil { + return fmt.Errorf("update member role: %w", err) + } + return nil +} + +func (s *PostgresOrgStore) RemoveMember(ctx context.Context, orgID, userID string) error { + _, err := s.db.ExecContext(ctx, ` + DELETE FROM organization_members WHERE org_id = $1 AND user_id = $2`, + orgID, userID) + if err != nil { + return fmt.Errorf("remove member: %w", err) + } + return nil +} + +func (s *PostgresOrgStore) CountOwners(ctx context.Context, orgID string) (int, error) { + var count int + err := s.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM organization_members + WHERE org_id = $1 AND role = 'owner'`, orgID).Scan(&count) + if err != nil { + return 0, fmt.Errorf("count owners: %w", err) + } + return count, nil +} + +// EnsureOrgWithOwner upserts the organization row and adds the caller as owner +// if they are not already a member. Handles the personal-org bootstrap path. +func (s *PostgresOrgStore) EnsureOrgWithOwner(ctx context.Context, orgID, orgName, userID, email, displayName string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + now := time.Now().UTC() + + // Upsert the org row. + _, err = tx.ExecContext(ctx, ` + INSERT INTO organizations (id, name, created_at, updated_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT (id) DO UPDATE SET updated_at = EXCLUDED.updated_at`, + orgID, orgName, now, now) + if err != nil { + return fmt.Errorf("upsert organization: %w", err) + } + + // Add the caller as owner only if they have no membership row yet. + _, err = tx.ExecContext(ctx, ` + INSERT INTO organization_members (org_id, user_id, email, display_name, role, created_at) + VALUES ($1, $2, $3, $4, 'owner', $5) + ON CONFLICT (org_id, user_id) DO NOTHING`, + orgID, userID, email, displayName, now) + if err != nil { + return fmt.Errorf("ensure owner membership: %w", err) + } + + return tx.Commit() +} + +// ── Invites ─────────────────────────────────────────────────────────────────── + +func (s *PostgresOrgStore) CreateInvite(ctx context.Context, inv *domain.Invite) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO org_invites (id, org_id, invited_email, role, token_hash, invited_by, expires_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + inv.ID, inv.OrgID, inv.InvitedEmail, inv.Role, inv.TokenHash, + inv.InvitedBy, inv.ExpiresAt, inv.CreatedAt) + if err != nil { + return fmt.Errorf("create invite: %w", err) + } + return nil +} + +func (s *PostgresOrgStore) FindInviteByToken(ctx context.Context, tokenHash string) (*domain.Invite, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT id, org_id, invited_email, role, token_hash, invited_by, + expires_at, accepted_at, created_at + FROM org_invites WHERE token_hash = $1`, tokenHash) + return scanInvite(row) +} + +func (s *PostgresOrgStore) ListInvites(ctx context.Context, orgID string) ([]*domain.Invite, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, org_id, invited_email, role, token_hash, invited_by, + expires_at, accepted_at, created_at + FROM org_invites + WHERE org_id = $1 AND accepted_at IS NULL AND expires_at > NOW() + ORDER BY created_at DESC`, orgID) + if err != nil { + return nil, fmt.Errorf("list invites: %w", err) + } + defer rows.Close() + return scanInvites(rows) +} + +// AcceptInvite marks the invite as accepted and adds the user as a member. +func (s *PostgresOrgStore) AcceptInvite(ctx context.Context, inviteID, userID, email, displayName string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() //nolint:errcheck + + var inv struct { + OrgID string + Role string + } + err = tx.QueryRowContext(ctx, ` + SELECT org_id, role FROM org_invites + WHERE id = $1 AND accepted_at IS NULL AND expires_at > NOW()`, inviteID). + Scan(&inv.OrgID, &inv.Role) + if err == sql.ErrNoRows { + return fmt.Errorf("invite not found or already used") + } + if err != nil { + return fmt.Errorf("find invite: %w", err) + } + + now := time.Now().UTC() + + _, err = tx.ExecContext(ctx, ` + UPDATE org_invites SET accepted_at = $1 WHERE id = $2`, now, inviteID) + if err != nil { + return fmt.Errorf("mark invite accepted: %w", err) + } + + _, err = tx.ExecContext(ctx, ` + INSERT INTO organization_members (org_id, user_id, email, display_name, role, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (org_id, user_id) DO UPDATE SET role = EXCLUDED.role`, + inv.OrgID, userID, email, displayName, inv.Role, now) + if err != nil { + return fmt.Errorf("add member from invite: %w", err) + } + + return tx.Commit() +} + +func (s *PostgresOrgStore) RevokeInvite(ctx context.Context, inviteID string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM org_invites WHERE id = $1`, inviteID) + if err != nil { + return fmt.Errorf("revoke invite: %w", err) + } + return nil +} + +// ── Scanners ────────────────────────────────────────────────────────────────── + +func scanOrg(row *sql.Row) (*domain.Organization, error) { + var o domain.Organization + if err := row.Scan(&o.ID, &o.Name, &o.Slug, &o.CreatedAt, &o.UpdatedAt); err != nil { + return nil, err + } + return &o, nil +} + +func scanOrgs(rows *sql.Rows) ([]*domain.Organization, error) { + var out []*domain.Organization + for rows.Next() { + var o domain.Organization + if err := rows.Scan(&o.ID, &o.Name, &o.Slug, &o.CreatedAt, &o.UpdatedAt); err != nil { + return nil, err + } + out = append(out, &o) + } + return out, rows.Err() +} + +func scanMember(row *sql.Row) (*domain.Member, error) { + var m domain.Member + if err := row.Scan(&m.OrgID, &m.UserID, &m.Email, &m.DisplayName, &m.Role, &m.CreatedAt); err != nil { + return nil, err + } + return &m, nil +} + +func scanMembers(rows *sql.Rows) ([]*domain.Member, error) { + var out []*domain.Member + for rows.Next() { + var m domain.Member + if err := rows.Scan(&m.OrgID, &m.UserID, &m.Email, &m.DisplayName, &m.Role, &m.CreatedAt); err != nil { + return nil, err + } + out = append(out, &m) + } + return out, rows.Err() +} + +func scanInvite(row *sql.Row) (*domain.Invite, error) { + var inv domain.Invite + var acceptedAt sql.NullTime + if err := row.Scan(&inv.ID, &inv.OrgID, &inv.InvitedEmail, &inv.Role, + &inv.TokenHash, &inv.InvitedBy, &inv.ExpiresAt, &acceptedAt, &inv.CreatedAt); err != nil { + return nil, err + } + if acceptedAt.Valid { + inv.AcceptedAt = &acceptedAt.Time + } + return &inv, nil +} + +func scanInvites(rows *sql.Rows) ([]*domain.Invite, error) { + var out []*domain.Invite + for rows.Next() { + var inv domain.Invite + var acceptedAt sql.NullTime + if err := rows.Scan(&inv.ID, &inv.OrgID, &inv.InvitedEmail, &inv.Role, + &inv.TokenHash, &inv.InvitedBy, &inv.ExpiresAt, &acceptedAt, &inv.CreatedAt); err != nil { + return nil, err + } + if acceptedAt.Valid { + inv.AcceptedAt = &acceptedAt.Time + } + out = append(out, &inv) + } + return out, rows.Err() +} + +// HashToken returns the SHA-256 hex of a raw token for safe DB storage. +func HashToken(raw string) string { + h := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(h[:]) +} diff --git a/internal/core/organizations/application/commands/create_organization.go b/internal/core/organizations/application/commands/create_organization.go index 9519c82..4238a86 100644 --- a/internal/core/organizations/application/commands/create_organization.go +++ b/internal/core/organizations/application/commands/create_organization.go @@ -53,6 +53,16 @@ func (h *CreateOrganizationHandler) Handle(ctx context.Context, cmd CreateOrgani return nil, fmt.Errorf("save organization: %w", err) } + // The caller becomes the first owner. + if cmd.CreatedBy != "" { + _ = h.orgs.AddMember(ctx, &domain.Member{ + OrgID: org.ID, + UserID: cmd.CreatedBy, + Role: domain.RoleOwner, + CreatedAt: now, + }) + } + return &CreateOrganizationResult{OrganizationID: org.ID, Slug: org.Slug}, nil } diff --git a/internal/core/organizations/domain/organization.go b/internal/core/organizations/domain/organization.go index 9dff660..bb3686a 100644 --- a/internal/core/organizations/domain/organization.go +++ b/internal/core/organizations/domain/organization.go @@ -12,3 +12,34 @@ type Organization struct { CreatedAt time.Time UpdatedAt time.Time } + +// Role constants for organization membership. +const ( + RoleOwner = "owner" + RoleAdmin = "admin" + RoleMember = "member" +) + +// Member represents a user's membership in an organization. +type Member struct { + OrgID string + UserID string + Email string + DisplayName string + Role string + CreatedAt time.Time +} + +// Invite is a pending email invitation to join an organization. +type Invite struct { + ID string + OrgID string + InvitedEmail string + Role string + Token string // raw token (only available at creation time) + TokenHash string + InvitedBy string + ExpiresAt time.Time + AcceptedAt *time.Time + CreatedAt time.Time +} diff --git a/internal/core/organizations/ports/repository.go b/internal/core/organizations/ports/repository.go index db9a005..9b17df0 100644 --- a/internal/core/organizations/ports/repository.go +++ b/internal/core/organizations/ports/repository.go @@ -8,9 +8,30 @@ import ( // OrganizationRepository is the persistence port for Organization aggregates. type OrganizationRepository interface { + // Org CRUD FindByID(ctx context.Context, id string) (*domain.Organization, error) FindBySlug(ctx context.Context, slug string) (*domain.Organization, error) - ListByUserID(ctx context.Context, userID string) ([]*domain.Organization, error) Save(ctx context.Context, org *domain.Organization) error + Update(ctx context.Context, org *domain.Organization) error Delete(ctx context.Context, id string) error + + // Membership + ListByUserID(ctx context.Context, userID string) ([]*domain.Organization, error) + ListMembers(ctx context.Context, orgID string) ([]*domain.Member, error) + GetMember(ctx context.Context, orgID, userID string) (*domain.Member, error) + AddMember(ctx context.Context, member *domain.Member) error + UpdateMemberRole(ctx context.Context, orgID, userID, role string) error + RemoveMember(ctx context.Context, orgID, userID string) error + CountOwners(ctx context.Context, orgID string) (int, error) + + // Bootstrap: upsert org row + add caller as owner if not already a member. + // Used to migrate existing "org-" orgs on first login. + EnsureOrgWithOwner(ctx context.Context, orgID, orgName, userID, email, displayName string) error + + // Invites + CreateInvite(ctx context.Context, invite *domain.Invite) error + FindInviteByToken(ctx context.Context, tokenHash string) (*domain.Invite, error) + ListInvites(ctx context.Context, orgID string) ([]*domain.Invite, error) + AcceptInvite(ctx context.Context, inviteID, userID, email, displayName string) error + RevokeInvite(ctx context.Context, inviteID string) error } diff --git a/internal/core/projects/adapters/http/handler.go b/internal/core/projects/adapters/http/handler.go index 86b5797..252dcb8 100644 --- a/internal/core/projects/adapters/http/handler.go +++ b/internal/core/projects/adapters/http/handler.go @@ -12,6 +12,7 @@ import ( "time" "github.com/go-chi/chi/v5" + orgports "github.com/kleffio/platform/internal/core/organizations/ports" "github.com/kleffio/platform/internal/core/projects/domain" "github.com/kleffio/platform/internal/core/projects/ports" "github.com/kleffio/platform/internal/shared/ids" @@ -24,11 +25,12 @@ var slugCleaner = regexp.MustCompile(`[^a-z0-9-]+`) type Handler struct { repo ports.ProjectRepository + orgs orgports.OrganizationRepository logger *slog.Logger } -func NewHandler(repo ports.ProjectRepository, logger *slog.Logger) *Handler { - return &Handler{repo: repo, logger: logger} +func NewHandler(repo ports.ProjectRepository, orgs orgports.OrganizationRepository, logger *slog.Logger) *Handler { + return &Handler{repo: repo, orgs: orgs, logger: logger} } func (h *Handler) RegisterRoutes(r chi.Router) { @@ -50,16 +52,11 @@ func (h *Handler) RegisterRoutes(r chi.Router) { // ── Project CRUD ───────────────────────────────────────────────────────────── func (h *Handler) list(w http.ResponseWriter, r *http.Request) { - orgID, err := organizationIDFromRequest(r) + orgID, err := h.resolveOrganizationID(r) if err != nil { writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) return } - if err := h.repo.EnsureOrganization(r.Context(), orgID, "Organization "+orgID); err != nil { - h.logger.Error("ensure organization", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to ensure organization"}) - return - } projects, err := h.repo.ListByOrganization(r.Context(), orgID) if err != nil { @@ -111,7 +108,7 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) return } - orgID, err := organizationIDFromRequest(r) + orgID, err := h.resolveOrganizationID(r) if err != nil { writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) return @@ -120,11 +117,6 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden: organization mismatch"}) return } - if err := h.repo.EnsureOrganization(r.Context(), orgID, "Organization "+orgID); err != nil { - h.logger.Error("ensure organization", "error", err) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to ensure organization"}) - return - } slug := normalizeSlug(req.Slug) if slug == "" { @@ -351,32 +343,58 @@ func (h *Handler) upsertGraphNode(w http.ResponseWriter, r *http.Request) { // ── Helpers ─────────────────────────────────────────────────────────────────── -func organizationIDFromRequest(r *http.Request) (string, error) { +// resolveOrganizationID determines the caller's active organization. +// +// If X-Organization-ID / organization_id is present: +// - verify the caller is a member of that org (membership table) +// +// Otherwise fall back to the caller's personal org (derived from JWT sub), +// bootstrapping the org row + owner membership on first use. +func (h *Handler) resolveOrganizationID(r *http.Request) (string, error) { queryOrgID := strings.TrimSpace(r.URL.Query().Get("organization_id")) headerOrgID := strings.TrimSpace(r.Header.Get("X-Organization-ID")) if queryOrgID != "" && headerOrgID != "" && queryOrgID != headerOrgID { return "", fmt.Errorf("forbidden: conflicting organization context") } - requestedOrgID := queryOrgID if requestedOrgID == "" { requestedOrgID = headerOrgID } - if claims, ok := middleware.ClaimsFromContext(r.Context()); ok { - if claims.Subject != "" { - callerOrgID := "org-" + normalizeSlug(claims.Subject) - if requestedOrgID != "" && requestedOrgID != callerOrgID { - return "", fmt.Errorf("forbidden: organization mismatch") + claims, hasClaims := middleware.ClaimsFromContext(r.Context()) + + // Explicit org in request — verify membership. + if requestedOrgID != "" { + if hasClaims && claims.Subject != "" && h.orgs != nil { + if _, err := h.orgs.GetMember(r.Context(), requestedOrgID, claims.Subject); err != nil { + return "", fmt.Errorf("forbidden: not a member of this organization") } - return callerOrgID, nil } + return requestedOrgID, nil } - if requestedOrgID != "" { - return requestedOrgID, nil + // No explicit org — use personal org derived from JWT sub. + if hasClaims && claims.Subject != "" { + personalOrgID := "org-" + normalizeSlug(claims.Subject) + if h.orgs != nil { + orgName := "My Organization" + if claims.Username != "" { + orgName = claims.Username + "'s Organization" + } + if err := h.orgs.EnsureOrgWithOwner(r.Context(), personalOrgID, orgName, + claims.Subject, claims.Email, claims.Username); err != nil { + return "", fmt.Errorf("failed to bootstrap organization") + } + } else { + // Fallback when org repo not available (tests/legacy). + if err := h.repo.EnsureOrganization(r.Context(), personalOrgID, "Organization "+personalOrgID); err != nil { + return "", fmt.Errorf("failed to ensure organization") + } + } + return personalOrgID, nil } + return "org-default", nil } @@ -386,7 +404,7 @@ func (h *Handler) authorizedProject(r *http.Request, projectID string) (*domain. return nil, err } - orgID, err := organizationIDFromRequest(r) + orgID, err := h.resolveOrganizationID(r) if err != nil { return nil, err } diff --git a/internal/core/workloads/adapters/http/handler.go b/internal/core/workloads/adapters/http/handler.go index 0c567ea..a7b77a4 100644 --- a/internal/core/workloads/adapters/http/handler.go +++ b/internal/core/workloads/adapters/http/handler.go @@ -13,6 +13,7 @@ import ( "time" "github.com/go-chi/chi/v5" + orgports "github.com/kleffio/platform/internal/core/organizations/ports" projectports "github.com/kleffio/platform/internal/core/projects/ports" "github.com/kleffio/platform/internal/core/workloads/application/commands" "github.com/kleffio/platform/internal/core/workloads/domain" @@ -30,6 +31,7 @@ const ( type Handler struct { projects projectports.ProjectRepository + orgs orgports.OrganizationRepository repo ports.Repository provision *commands.ProvisionWorkloadHandler action *commands.WorkloadActionHandler @@ -39,8 +41,8 @@ type Handler struct { var orgSlugCleaner = regexp.MustCompile(`[^a-z0-9-]+`) -func NewHandler(projects projectports.ProjectRepository, repo ports.Repository, provision *commands.ProvisionWorkloadHandler, action *commands.WorkloadActionHandler, bus *events.Bus, logger *slog.Logger) *Handler { - return &Handler{projects: projects, repo: repo, provision: provision, action: action, bus: bus, logger: logger} +func NewHandler(projects projectports.ProjectRepository, orgs orgports.OrganizationRepository, repo ports.Repository, provision *commands.ProvisionWorkloadHandler, action *commands.WorkloadActionHandler, bus *events.Bus, logger *slog.Logger) *Handler { + return &Handler{projects: projects, orgs: orgs, repo: repo, provision: provision, action: action, bus: bus, logger: logger} } func (h *Handler) RegisterRoutes(r chi.Router) { @@ -59,7 +61,7 @@ func (h *Handler) RegisterInternalRoutes(r chi.Router) { func (h *Handler) provisionWorkload(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "projectID") - orgID := callerOrganizationID(r.Context()) + orgID := h.callerOrganizationID(r) if err := h.ensureProjectAccess(r.Context(), projectID, orgID); err != nil { if errors.Is(err, sql.ErrNoRows) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) @@ -134,7 +136,7 @@ func (h *Handler) provisionWorkload(w http.ResponseWriter, r *http.Request) { func (h *Handler) list(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "projectID") - orgID := callerOrganizationID(r.Context()) + orgID := h.callerOrganizationID(r) if err := h.ensureProjectAccess(r.Context(), projectID, orgID); err != nil { if errors.Is(err, sql.ErrNoRows) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) @@ -159,7 +161,7 @@ func (h *Handler) get(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "workload not found"}) return } - orgID := callerOrganizationID(r.Context()) + orgID := h.callerOrganizationID(r) if orgID != "" && workload.OrganizationID != orgID { writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden: workload does not belong to caller organization"}) return @@ -267,7 +269,7 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request) { func (h *Handler) runAction(w http.ResponseWriter, r *http.Request, action queue.JobType) { projectID := chi.URLParam(r, "projectID") workloadID := chi.URLParam(r, "id") - orgID := callerOrganizationID(r.Context()) + orgID := h.callerOrganizationID(r) if err := h.ensureProjectAccess(r.Context(), projectID, orgID); err != nil { if errors.Is(err, sql.ErrNoRows) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) @@ -309,11 +311,30 @@ func writeJSON(w http.ResponseWriter, status int, body any) { _ = json.NewEncoder(w).Encode(body) } -func callerOrganizationID(ctx context.Context) string { - claims, ok := middleware.ClaimsFromContext(ctx) +// callerOrganizationID resolves the active org from X-Organization-ID header, +// verifying membership. Falls back to the personal org derived from JWT sub. +func (h *Handler) callerOrganizationID(r *http.Request) string { + headerOrgID := strings.TrimSpace(r.Header.Get("X-Organization-ID")) + if headerOrgID == "" { + headerOrgID = strings.TrimSpace(r.URL.Query().Get("organization_id")) + } + + claims, ok := middleware.ClaimsFromContext(r.Context()) if !ok || claims.Subject == "" { - return "" + return headerOrgID + } + + if headerOrgID != "" { + // Verify membership when an explicit org is requested. + if h.orgs != nil { + if _, err := h.orgs.GetMember(r.Context(), headerOrgID, claims.Subject); err != nil { + return "" // not a member — access denied at ensureProjectAccess + } + } + return headerOrgID } + + // Personal org fallback. return "org-" + normalizeOrgSlug(claims.Subject) } diff --git a/internal/database/migrations/008_org_members.sql b/internal/database/migrations/008_org_members.sql new file mode 100644 index 0000000..bada4bd --- /dev/null +++ b/internal/database/migrations/008_org_members.sql @@ -0,0 +1,35 @@ +-- Tracks which users belong to which organizations and their role. +-- Roles: owner | admin | member +-- Multiple owners are allowed per org. + +CREATE TABLE IF NOT EXISTS organization_members ( + org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + display_name TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'member', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (org_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_org_members_user_id ON organization_members(user_id); + +-- Back-fill: for any organization that was auto-created from a user ID +-- (pattern: "org-"), we cannot reconstruct the original user_id here, +-- so bootstrap happens at runtime on first login (see EnsureOrganization). + +-- Pending email invitations into an organization. +CREATE TABLE IF NOT EXISTS org_invites ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + invited_email TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + token_hash TEXT NOT NULL UNIQUE, + invited_by TEXT NOT NULL DEFAULT '', + expires_at TIMESTAMPTZ NOT NULL, + accepted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_org_invites_org_id ON org_invites(org_id); +CREATE INDEX IF NOT EXISTS idx_org_invites_token ON org_invites(token_hash); From 88e49e82da7d42507b27e6abaab4484dcdf68218 Mon Sep 17 00:00:00 2001 From: jeremy Date: Mon, 20 Apr 2026 17:24:56 -0400 Subject: [PATCH 3/4] collaboration core features --- .air.toml | 2 + internal/bootstrap/container.go | 6 +- .../adapters/persistence/store.go | 12 + .../core/notifications/application/service.go | 5 + .../core/notifications/ports/repository.go | 4 + .../organizations/adapters/http/handler.go | 18 - .../adapters/persistence/store.go | 9 + .../core/organizations/ports/repository.go | 1 + .../core/projects/adapters/http/handler.go | 441 +++++++++++++++++- .../projects/adapters/persistence/store.go | 239 ++++++++++ internal/core/projects/domain/project.go | 46 ++ internal/core/projects/ports/repository.go | 16 + .../core/workloads/adapters/http/handler.go | 74 ++- .../010_project_members_invites.sql | 27 ++ packages/adapters/http/middleware.go | 6 + plugins.local.json | 0 16 files changed, 860 insertions(+), 46 deletions(-) create mode 100644 internal/database/migrations/010_project_members_invites.sql create mode 100644 plugins.local.json diff --git a/.air.toml b/.air.toml index cb35a20..d63e303 100644 --- a/.air.toml +++ b/.air.toml @@ -10,6 +10,8 @@ tmp_dir = "/tmp/air" delay = 500 kill_delay = "1s" rerun = false + poll = true + poll_interval = 1000 [log] time = false diff --git a/internal/bootstrap/container.go b/internal/bootstrap/container.go index 0f2b304..3138afe 100644 --- a/internal/bootstrap/container.go +++ b/internal/bootstrap/container.go @@ -169,13 +169,9 @@ func NewContainer(cfg *Config, logger *slog.Logger) (*Container, error) { AuthHandler: pluginhttp.NewAuthHandler(pluginMgr, logger), SetupHandler: pluginhttp.NewSetupHandler(pluginMgr, catalogRegistry, logger), CatalogHandler: cataloghttp.NewHandler(catalogStore, logger), -<<<<<<< HEAD - OrganizationsHandler: organizationshttp.NewHandler(orgStore, logger), -======= OrganizationsHandler: organizationshttp.NewHandler(orgStore, notificationSvc, logger), ->>>>>>> 3dfd032d9615a84ab165078151f46e398eba8f83 DeploymentsHandler: deploymentshttp.NewHandler(createDeployment, serverAction, deploymentStore, cfg.SecretKey, logger), - ProjectsHandler: projectshttp.NewHandler(projectsStore, orgStore, logger), + ProjectsHandler: projectshttp.NewHandler(projectsStore, orgStore, notificationSvc, logger), WorkloadsHandler: workloadshttp.NewHandler(projectsStore, orgStore, workloadsStore, provisionHandler, workloadAction, bus, logger), NodesHandler: nodeshttp.NewHandler(nodeStore, logger), BillingHandler: billinghttp.NewHandler(logger), diff --git a/internal/core/notifications/adapters/persistence/store.go b/internal/core/notifications/adapters/persistence/store.go index 38a469c..7ac88a9 100644 --- a/internal/core/notifications/adapters/persistence/store.go +++ b/internal/core/notifications/adapters/persistence/store.go @@ -61,6 +61,18 @@ func (s *PostgresNotificationStore) MarkAllRead(ctx context.Context, userID stri return nil } +func (s *PostgresNotificationStore) MarkReadByInviteID(ctx context.Context, userID, inviteID string) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE notifications SET read_at = $1 + WHERE user_id = $2 AND type = 'project_invitation' AND read_at IS NULL + AND data->>'invite_id' = $3`, + time.Now().UTC(), userID, inviteID) + if err != nil { + return fmt.Errorf("mark notification read by invite id: %w", err) + } + return nil +} + func (s *PostgresNotificationStore) Delete(ctx context.Context, id, userID string) error { _, err := s.db.ExecContext(ctx, ` DELETE FROM notifications WHERE id = $1 AND user_id = $2`, diff --git a/internal/core/notifications/application/service.go b/internal/core/notifications/application/service.go index 7e61d8b..1b04789 100644 --- a/internal/core/notifications/application/service.go +++ b/internal/core/notifications/application/service.go @@ -76,6 +76,11 @@ func (s *Service) MarkAllRead(ctx context.Context, userID string) error { return s.repo.MarkAllRead(ctx, userID) } +// MarkReadByInviteID marks the project_invitation notification for this invite as read. +func (s *Service) MarkReadByInviteID(ctx context.Context, userID, inviteID string) error { + return s.repo.MarkReadByInviteID(ctx, userID, inviteID) +} + // Delete removes a notification. The notification must belong to userID. func (s *Service) Delete(ctx context.Context, id, userID string) error { return s.repo.Delete(ctx, id, userID) diff --git a/internal/core/notifications/ports/repository.go b/internal/core/notifications/ports/repository.go index 91f88b4..e024daf 100644 --- a/internal/core/notifications/ports/repository.go +++ b/internal/core/notifications/ports/repository.go @@ -27,6 +27,10 @@ type NotificationRepository interface { // MarkAllRead sets read_at to now for every unread notification owned by userID. MarkAllRead(ctx context.Context, userID string) error + // MarkReadByInviteID marks as read any unread project_invitation notification for userID + // whose data->>'invite_id' matches inviteID. + MarkReadByInviteID(ctx context.Context, userID, inviteID string) error + // Delete removes a notification owned by userID. Delete(ctx context.Context, id, userID string) error } diff --git a/internal/core/organizations/adapters/http/handler.go b/internal/core/organizations/adapters/http/handler.go index 16f1080..86935ca 100644 --- a/internal/core/organizations/adapters/http/handler.go +++ b/internal/core/organizations/adapters/http/handler.go @@ -12,11 +12,8 @@ import ( "time" "github.com/go-chi/chi/v5" -<<<<<<< HEAD -======= "github.com/kleffio/platform/internal/core/notifications/application" notificationsdomain "github.com/kleffio/platform/internal/core/notifications/domain" ->>>>>>> 3dfd032d9615a84ab165078151f46e398eba8f83 "github.com/kleffio/platform/internal/core/organizations/adapters/persistence" "github.com/kleffio/platform/internal/core/organizations/domain" "github.com/kleffio/platform/internal/core/organizations/ports" @@ -28,14 +25,6 @@ const basePath = "/api/v1/organizations" // Handler groups all HTTP endpoints for the organizations module. type Handler struct { -<<<<<<< HEAD - repo ports.OrganizationRepository - logger *slog.Logger -} - -func NewHandler(repo ports.OrganizationRepository, logger *slog.Logger) *Handler { - return &Handler{repo: repo, logger: logger} -======= repo ports.OrganizationRepository notifications *application.Service logger *slog.Logger @@ -43,7 +32,6 @@ func NewHandler(repo ports.OrganizationRepository, logger *slog.Logger) *Handler func NewHandler(repo ports.OrganizationRepository, notifications *application.Service, logger *slog.Logger) *Handler { return &Handler{repo: repo, notifications: notifications, logger: logger} ->>>>>>> 3dfd032d9615a84ab165078151f46e398eba8f83 } // RegisterRoutes attaches all organizations routes to the provided router. @@ -435,8 +423,6 @@ func (h *Handler) createInvite(w http.ResponseWriter, r *http.Request) { return } -<<<<<<< HEAD -======= // Notify the inviting admin that the invite was dispatched. if h.notifications != nil { _, _ = h.notifications.Create(r.Context(), application.CreateInput{ @@ -448,7 +434,6 @@ func (h *Handler) createInvite(w http.ResponseWriter, r *http.Request) { }) } ->>>>>>> 3dfd032d9615a84ab165078151f46e398eba8f83 writeJSON(w, http.StatusCreated, map[string]any{ "id": inv.ID, "org_id": inv.OrgID, @@ -544,8 +529,6 @@ func (h *Handler) acceptInvite(w http.ResponseWriter, r *http.Request) { return } -<<<<<<< HEAD -======= // Notify the new member that they joined the organization. if h.notifications != nil { org, orgErr := h.repo.FindByID(r.Context(), inv.OrgID) @@ -562,7 +545,6 @@ func (h *Handler) acceptInvite(w http.ResponseWriter, r *http.Request) { }) } ->>>>>>> 3dfd032d9615a84ab165078151f46e398eba8f83 writeJSON(w, http.StatusOK, map[string]string{"org_id": inv.OrgID}) } diff --git a/internal/core/organizations/adapters/persistence/store.go b/internal/core/organizations/adapters/persistence/store.go index d9a958d..379aa5c 100644 --- a/internal/core/organizations/adapters/persistence/store.go +++ b/internal/core/organizations/adapters/persistence/store.go @@ -102,6 +102,15 @@ func (s *PostgresOrgStore) GetMember(ctx context.Context, orgID, userID string) return scanMember(row) } +func (s *PostgresOrgStore) FindMemberByEmail(ctx context.Context, email string) (*domain.Member, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT org_id, user_id, email, display_name, role, created_at + FROM organization_members + WHERE LOWER(email) = LOWER($1) + LIMIT 1`, email) + return scanMember(row) +} + func (s *PostgresOrgStore) AddMember(ctx context.Context, m *domain.Member) error { _, err := s.db.ExecContext(ctx, ` INSERT INTO organization_members (org_id, user_id, email, display_name, role, created_at) diff --git a/internal/core/organizations/ports/repository.go b/internal/core/organizations/ports/repository.go index 9b17df0..1afcd35 100644 --- a/internal/core/organizations/ports/repository.go +++ b/internal/core/organizations/ports/repository.go @@ -19,6 +19,7 @@ type OrganizationRepository interface { ListByUserID(ctx context.Context, userID string) ([]*domain.Organization, error) ListMembers(ctx context.Context, orgID string) ([]*domain.Member, error) GetMember(ctx context.Context, orgID, userID string) (*domain.Member, error) + FindMemberByEmail(ctx context.Context, email string) (*domain.Member, error) AddMember(ctx context.Context, member *domain.Member) error UpdateMemberRole(ctx context.Context, orgID, userID, role string) error RemoveMember(ctx context.Context, orgID, userID string) error diff --git a/internal/core/projects/adapters/http/handler.go b/internal/core/projects/adapters/http/handler.go index 252dcb8..5237aa0 100644 --- a/internal/core/projects/adapters/http/handler.go +++ b/internal/core/projects/adapters/http/handler.go @@ -1,7 +1,9 @@ package http import ( + "crypto/sha256" "database/sql" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -12,6 +14,8 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/kleffio/platform/internal/core/notifications/application" + notificationsdomain "github.com/kleffio/platform/internal/core/notifications/domain" orgports "github.com/kleffio/platform/internal/core/organizations/ports" "github.com/kleffio/platform/internal/core/projects/domain" "github.com/kleffio/platform/internal/core/projects/ports" @@ -24,13 +28,14 @@ const basePath = "/api/v1/projects" var slugCleaner = regexp.MustCompile(`[^a-z0-9-]+`) type Handler struct { - repo ports.ProjectRepository - orgs orgports.OrganizationRepository - logger *slog.Logger + repo ports.ProjectRepository + orgs orgports.OrganizationRepository + notifications *application.Service + logger *slog.Logger } -func NewHandler(repo ports.ProjectRepository, orgs orgports.OrganizationRepository, logger *slog.Logger) *Handler { - return &Handler{repo: repo, orgs: orgs, logger: logger} +func NewHandler(repo ports.ProjectRepository, orgs orgports.OrganizationRepository, notifications *application.Service, logger *slog.Logger) *Handler { + return &Handler{repo: repo, orgs: orgs, notifications: notifications, logger: logger} } func (h *Handler) RegisterRoutes(r chi.Router) { @@ -47,6 +52,21 @@ func (h *Handler) RegisterRoutes(r chi.Router) { // Graph node positions r.Get(basePath+"/{id}/graph-nodes", h.listGraphNodes) r.Put(basePath+"/{id}/graph-nodes/{workloadID}", h.upsertGraphNode) + + // Members sub-resource + r.Get(basePath+"/{id}/members", h.listMembers) + r.Post(basePath+"/{id}/members", h.addMember) + r.Patch(basePath+"/{id}/members/{userID}", h.updateMemberRole) + r.Delete(basePath+"/{id}/members/{userID}", h.removeMember) + + // Invites sub-resource + r.Get(basePath+"/{id}/invites", h.listInvites) + r.Post(basePath+"/{id}/invites", h.createInvite) + r.Delete(basePath+"/{id}/invites/{inviteID}", h.revokeInvite) + + // Public invite resolution + accept + r.Get("/api/v1/project-invites/{token}", h.resolveInvite) + r.Post("/api/v1/project-invites/{token}/accept", h.acceptInvite) } // ── Project CRUD ───────────────────────────────────────────────────────────── @@ -64,6 +84,21 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list projects"}) return } + + // Also include projects in other orgs where the user is an explicit member. + if claims, ok := middleware.ClaimsFromContext(r.Context()); ok && claims.Subject != "" { + memberProjects, _ := h.repo.ListByMember(r.Context(), claims.Subject) + seen := make(map[string]struct{}, len(projects)) + for _, p := range projects { + seen[p.ID] = struct{}{} + } + for _, p := range memberProjects { + if _, exists := seen[p.ID]; !exists { + projects = append(projects, p) + } + } + } + if len(projects) == 0 { now := time.Now().UTC() @@ -88,6 +123,16 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create default project"}) return } + if claims, ok := middleware.ClaimsFromContext(r.Context()); ok && claims.Subject != "" { + _ = h.repo.AddMember(r.Context(), &domain.ProjectMember{ + ProjectID: defaultProject.ID, + UserID: claims.Subject, + Email: claims.Email, + DisplayName: claims.Username, + Role: domain.RoleOwner, + CreatedAt: now, + }) + } projects = []*domain.Project{defaultProject} } @@ -143,6 +188,15 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create project"}) return } + claims, _ := middleware.ClaimsFromContext(r.Context()) + _ = h.repo.AddMember(r.Context(), &domain.ProjectMember{ + ProjectID: project.ID, + UserID: claims.Subject, + Email: claims.Email, + DisplayName: claims.Username, + Role: domain.RoleOwner, + CreatedAt: now, + }) writeJSON(w, http.StatusCreated, project) } @@ -341,6 +395,337 @@ func (h *Handler) upsertGraphNode(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, node) } +// ── Members ─────────────────────────────────────────────────────────────────── + +func (h *Handler) listMembers(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + if _, err := h.authorizedProject(r, projectID); err != nil { + writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) + return + } + members, err := h.repo.ListMembers(r.Context(), projectID) + if err != nil { + h.logger.Error("list project members", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list members"}) + return + } + if members == nil { + members = []*domain.ProjectMember{} + } + writeJSON(w, http.StatusOK, map[string]any{"members": members}) +} + +func (h *Handler) addMember(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + if _, err := h.authorizedProjectRole(r, projectID, domain.RoleMaintainer); err != nil { + writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) + return + } + var req struct { + UserID string `json:"user_id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"}) + return + } + if req.UserID == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user_id is required"}) + return + } + if domain.RoleRank(req.Role) < 0 { + req.Role = domain.RoleDeveloper + } + // Only owners can add a member with owner role. + if req.Role == domain.RoleOwner { + if _, err := h.authorizedProjectRole(r, projectID, domain.RoleOwner); err != nil { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "only an owner can add another owner"}) + return + } + } + claims, _ := middleware.ClaimsFromContext(r.Context()) + member := &domain.ProjectMember{ + ProjectID: projectID, + UserID: req.UserID, + Email: req.Email, + DisplayName: req.DisplayName, + Role: req.Role, + InvitedBy: claims.Subject, + CreatedAt: time.Now().UTC(), + } + if err := h.repo.AddMember(r.Context(), member); err != nil { + h.logger.Error("add project member", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to add member"}) + return + } + writeJSON(w, http.StatusCreated, member) +} + +func (h *Handler) updateMemberRole(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + userID := chi.URLParam(r, "userID") + if _, err := h.authorizedProjectRole(r, projectID, domain.RoleMaintainer); err != nil { + writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) + return + } + var req struct { + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || domain.RoleRank(req.Role) < 0 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "valid role is required"}) + return + } + // Granting owner, or modifying an existing owner, requires owner. + target, _ := h.repo.GetMember(r.Context(), projectID, userID) + if req.Role == domain.RoleOwner || (target != nil && target.Role == domain.RoleOwner) { + if _, err := h.authorizedProjectRole(r, projectID, domain.RoleOwner); err != nil { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "only an owner can grant or change the owner role"}) + return + } + } + if err := h.repo.UpdateMemberRole(r.Context(), projectID, userID, req.Role); err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "member not found"}) + return + } + h.logger.Error("update member role", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to update role"}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) removeMember(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + userID := chi.URLParam(r, "userID") + if _, err := h.authorizedProjectRole(r, projectID, domain.RoleMaintainer); err != nil { + writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) + return + } + // Removing an owner requires owner. + target, _ := h.repo.GetMember(r.Context(), projectID, userID) + if target != nil && target.Role == domain.RoleOwner { + if _, err := h.authorizedProjectRole(r, projectID, domain.RoleOwner); err != nil { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "only an owner can remove another owner"}) + return + } + } + if err := h.repo.RemoveMember(r.Context(), projectID, userID); err != nil { + h.logger.Error("remove member", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to remove member"}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ── Invites ─────────────────────────────────────────────────────────────────── + +func (h *Handler) listInvites(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + if _, err := h.authorizedProjectRole(r, projectID, domain.RoleMaintainer); err != nil { + writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) + return + } + invites, err := h.repo.ListInvites(r.Context(), projectID) + if err != nil { + h.logger.Error("list project invites", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list invites"}) + return + } + if invites == nil { + invites = []*domain.ProjectInvite{} + } + writeJSON(w, http.StatusOK, map[string]any{"invites": invites}) +} + +func (h *Handler) createInvite(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + project, err := h.authorizedProjectRole(r, projectID, domain.RoleMaintainer) + if err != nil { + writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) + return + } + var req struct { + Email string `json:"email"` + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"}) + return + } + if strings.TrimSpace(req.Email) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "email is required"}) + return + } + if domain.RoleRank(req.Role) < 0 { + req.Role = domain.RoleDeveloper + } + // Only owners can invite with owner role. + if req.Role == domain.RoleOwner { + if _, err := h.authorizedProjectRole(r, projectID, domain.RoleOwner); err != nil { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "only an owner can invite another owner"}) + return + } + } + claims, _ := middleware.ClaimsFromContext(r.Context()) + + // Validate that the email belongs to a registered user and get their ID. + invitedMember, err := h.orgs.FindMemberByEmail(r.Context(), strings.TrimSpace(req.Email)) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no registered user found with that email"}) + return + } + + // Reject if they're already a member. + if _, memberErr := h.repo.GetMember(r.Context(), projectID, invitedMember.UserID); memberErr == nil { + writeJSON(w, http.StatusConflict, map[string]string{"error": "user is already a member of this project"}) + return + } + + // Reject if there's already a pending invite for this email. + if _, activeErr := h.repo.FindActiveInviteByEmail(r.Context(), projectID, req.Email); activeErr == nil { + writeJSON(w, http.StatusConflict, map[string]string{"error": "a pending invite already exists for this email"}) + return + } + + inv := &domain.ProjectInvite{ + ProjectID: projectID, + InvitedEmail: req.Email, + Role: req.Role, + InvitedBy: claims.Subject, + } + if err := h.repo.CreateInvite(r.Context(), inv); err != nil { + h.logger.Error("create project invite", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create invite"}) + return + } + + if h.notifications != nil { + _, _ = h.notifications.Create(r.Context(), application.CreateInput{ + UserID: invitedMember.UserID, + Type: notificationsdomain.TypeProjectInvitation, + Title: "You've been invited to a project", + Body: fmt.Sprintf("You've been invited to join project %s.", project.Name), + Data: map[string]any{"project_id": projectID, "invite_id": inv.ID, "token": inv.Token}, + }) + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "id": inv.ID, + "project_id": inv.ProjectID, + "invited_email": inv.InvitedEmail, + "role": inv.Role, + "token": inv.Token, + "expires_at": inv.ExpiresAt, + "created_at": inv.CreatedAt, + }) +} + +func (h *Handler) revokeInvite(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "id") + inviteID := chi.URLParam(r, "inviteID") + if _, err := h.authorizedProjectRole(r, projectID, domain.RoleMaintainer); err != nil { + writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) + return + } + if err := h.repo.RevokeInvite(r.Context(), projectID, inviteID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "invite not found"}) + return + } + h.logger.Error("revoke invite", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to revoke invite"}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) resolveInvite(w http.ResponseWriter, r *http.Request) { + token := chi.URLParam(r, "token") + tokenHash := hashToken(token) + inv, err := h.repo.FindInviteByToken(r.Context(), tokenHash) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "invite not found"}) + return + } + if inv.AcceptedAt != nil { + writeJSON(w, http.StatusConflict, map[string]string{"error": "invite already accepted"}) + return + } + if time.Now().After(inv.ExpiresAt) { + writeJSON(w, http.StatusGone, map[string]string{"error": "invite expired"}) + return + } + project, _ := h.repo.FindByID(r.Context(), inv.ProjectID) + resp := map[string]any{ + "id": inv.ID, + "project_id": inv.ProjectID, + "invited_email": inv.InvitedEmail, + "role": inv.Role, + "expires_at": inv.ExpiresAt, + } + if project != nil { + resp["project_name"] = project.Name + resp["project_slug"] = project.Slug + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *Handler) acceptInvite(w http.ResponseWriter, r *http.Request) { + token := chi.URLParam(r, "token") + tokenHash := hashToken(token) + claims, ok := middleware.ClaimsFromContext(r.Context()) + if !ok { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + inv, err := h.repo.AcceptInvite(r.Context(), tokenHash, claims.Subject, claims.Email, claims.Username) + if err != nil { + if strings.Contains(err.Error(), "already accepted") { + writeJSON(w, http.StatusConflict, map[string]string{"error": "invite already accepted"}) + return + } + if strings.Contains(err.Error(), "expired") { + writeJSON(w, http.StatusGone, map[string]string{"error": "invite expired"}) + return + } + if errors.Is(err, sql.ErrNoRows) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "invite not found"}) + return + } + h.logger.Error("accept project invite", "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to accept invite"}) + return + } + + if h.notifications != nil { + // Mark the invite notification as read now that it has been accepted. + _ = h.notifications.MarkReadByInviteID(r.Context(), claims.Subject, inv.ID) + + project, _ := h.repo.FindByID(r.Context(), inv.ProjectID) + projectName := inv.ProjectID + if project != nil { + projectName = project.Name + } + _, _ = h.notifications.Create(r.Context(), application.CreateInput{ + UserID: claims.Subject, + Type: notificationsdomain.TypeProjectInvitation, + Title: "You joined a project", + Body: fmt.Sprintf("You have successfully joined %s.", projectName), + Data: map[string]any{"project_id": inv.ProjectID}, + }) + } + + writeJSON(w, http.StatusOK, map[string]string{"project_id": inv.ProjectID}) +} + +func hashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} + // ── Helpers ─────────────────────────────────────────────────────────────────── // resolveOrganizationID determines the caller's active organization. @@ -408,8 +793,50 @@ func (h *Handler) authorizedProject(r *http.Request, projectID string) (*domain. if err != nil { return nil, err } - if orgID != "" && project.OrganizationID != orgID { - return nil, fmt.Errorf("forbidden: project does not belong to caller organization") + + // Org matches — access granted. + if orgID == "" || project.OrganizationID == orgID { + return project, nil + } + + // Org doesn't match, but the caller may be an explicit project member + // (e.g. an invited user whose personal org differs from the project owner's org). + if claims, ok := middleware.ClaimsFromContext(r.Context()); ok && claims.Subject != "" { + if _, memberErr := h.repo.GetMember(r.Context(), projectID, claims.Subject); memberErr == nil { + return project, nil + } + } + + return nil, fmt.Errorf("forbidden: project does not belong to caller organization") +} + +// authorizedProjectRole checks org membership AND project-level role. +// minRole is the minimum role required (viewer < developer < maintainer < owner). +func (h *Handler) authorizedProjectRole(r *http.Request, projectID, minRole string) (*domain.Project, error) { + project, err := h.authorizedProject(r, projectID) + if err != nil { + return nil, err + } + claims, ok := middleware.ClaimsFromContext(r.Context()) + if !ok { + return nil, fmt.Errorf("forbidden: unauthorized") + } + member, err := h.repo.GetMember(r.Context(), projectID, claims.Subject) + if err != nil { + // If no member row exists, fall back to treating org owners as project owners. + if errors.Is(err, sql.ErrNoRows) { + if h.orgs != nil { + orgMember, orgErr := h.orgs.GetMember(r.Context(), project.OrganizationID, claims.Subject) + if orgErr == nil && orgMember.Role == "owner" { + return project, nil + } + } + return nil, fmt.Errorf("forbidden: not a member of this project") + } + return nil, err + } + if domain.RoleRank(member.Role) < domain.RoleRank(minRole) { + return nil, fmt.Errorf("forbidden: requires %s role or higher", minRole) } return project, nil } diff --git a/internal/core/projects/adapters/persistence/store.go b/internal/core/projects/adapters/persistence/store.go index b7e7e94..f01b498 100644 --- a/internal/core/projects/adapters/persistence/store.go +++ b/internal/core/projects/adapters/persistence/store.go @@ -2,12 +2,16 @@ package persistence import ( "context" + "crypto/rand" + "crypto/sha256" "database/sql" + "encoding/hex" "fmt" "time" "github.com/kleffio/platform/internal/core/projects/domain" "github.com/kleffio/platform/internal/core/projects/ports" + "github.com/kleffio/platform/internal/shared/ids" ) type PostgresProjectStore struct { @@ -56,6 +60,28 @@ func (s *PostgresProjectStore) FindBySlug(ctx context.Context, organizationID, s return scanProject(row) } +func (s *PostgresProjectStore) ListByMember(ctx context.Context, userID string) ([]*domain.Project, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT DISTINCT p.id, p.organization_id, p.slug, p.name, p.is_default, p.created_at, p.updated_at + FROM projects p + INNER JOIN project_members pm ON pm.project_id = p.id + WHERE pm.user_id = $1 + ORDER BY p.created_at ASC`, userID) + if err != nil { + return nil, fmt.Errorf("list projects by member: %w", err) + } + defer rows.Close() + var out []*domain.Project + for rows.Next() { + p, err := scanProject(rows) + if err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + func (s *PostgresProjectStore) ListByOrganization(ctx context.Context, organizationID string) ([]*domain.Project, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, organization_id, slug, name, is_default, created_at, updated_at @@ -235,6 +261,195 @@ func (s *PostgresProjectStore) UpsertGraphNode(ctx context.Context, node *domain return nil } +// ── Project members ─────────────────────────────────────────────────────────── + +func (s *PostgresProjectStore) ListMembers(ctx context.Context, projectID string) ([]*domain.ProjectMember, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT project_id, user_id, email, display_name, role, invited_by, created_at + FROM project_members WHERE project_id = $1 ORDER BY created_at ASC`, projectID) + if err != nil { + return nil, fmt.Errorf("list project members: %w", err) + } + defer rows.Close() + var out []*domain.ProjectMember + for rows.Next() { + m, err := scanMember(rows) + if err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +func (s *PostgresProjectStore) GetMember(ctx context.Context, projectID, userID string) (*domain.ProjectMember, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT project_id, user_id, email, display_name, role, invited_by, created_at + FROM project_members WHERE project_id = $1 AND user_id = $2`, projectID, userID) + return scanMember(row) +} + +func (s *PostgresProjectStore) AddMember(ctx context.Context, m *domain.ProjectMember) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO project_members (project_id, user_id, email, display_name, role, invited_by, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7) + ON CONFLICT (project_id, user_id) DO UPDATE SET + role = EXCLUDED.role, + display_name = EXCLUDED.display_name, + email = EXCLUDED.email`, + m.ProjectID, m.UserID, m.Email, m.DisplayName, m.Role, m.InvitedBy, m.CreatedAt) + if err != nil { + return fmt.Errorf("add project member: %w", err) + } + return nil +} + +func (s *PostgresProjectStore) UpdateMemberRole(ctx context.Context, projectID, userID, role string) error { + res, err := s.db.ExecContext(ctx, + `UPDATE project_members SET role=$3 WHERE project_id=$1 AND user_id=$2`, projectID, userID, role) + if err != nil { + return fmt.Errorf("update member role: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} + +func (s *PostgresProjectStore) RemoveMember(ctx context.Context, projectID, userID string) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM project_members WHERE project_id=$1 AND user_id=$2`, projectID, userID) + return err +} + +// ── Project invites ─────────────────────────────────────────────────────────── + +func (s *PostgresProjectStore) ListInvites(ctx context.Context, projectID string) ([]*domain.ProjectInvite, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, project_id, invited_email, role, invited_by, expires_at, accepted_at, created_at + FROM project_invites + WHERE project_id = $1 AND accepted_at IS NULL AND expires_at > NOW() + ORDER BY created_at DESC`, projectID) + if err != nil { + return nil, fmt.Errorf("list project invites: %w", err) + } + defer rows.Close() + var out []*domain.ProjectInvite + for rows.Next() { + inv, err := scanInvite(rows) + if err != nil { + return nil, err + } + out = append(out, inv) + } + return out, rows.Err() +} + +func (s *PostgresProjectStore) FindInviteByToken(ctx context.Context, tokenHash string) (*domain.ProjectInvite, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT id, project_id, invited_email, role, invited_by, expires_at, accepted_at, created_at + FROM project_invites WHERE token_hash = $1`, tokenHash) + return scanInvite(row) +} + +func (s *PostgresProjectStore) FindActiveInviteByEmail(ctx context.Context, projectID, email string) (*domain.ProjectInvite, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT id, project_id, invited_email, role, invited_by, expires_at, accepted_at, created_at + FROM project_invites + WHERE project_id = $1 AND LOWER(invited_email) = LOWER($2) + AND accepted_at IS NULL AND expires_at > NOW() + LIMIT 1`, projectID, email) + return scanInvite(row) +} + +func (s *PostgresProjectStore) CreateInvite(ctx context.Context, inv *domain.ProjectInvite) error { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return fmt.Errorf("generate invite token: %w", err) + } + token := hex.EncodeToString(raw) + h := sha256.Sum256([]byte(token)) + tokenHash := hex.EncodeToString(h[:]) + + inv.ID = ids.New() + inv.Token = token + inv.TokenHash = tokenHash + if inv.CreatedAt.IsZero() { + inv.CreatedAt = time.Now().UTC() + } + if inv.ExpiresAt.IsZero() { + inv.ExpiresAt = inv.CreatedAt.Add(7 * 24 * time.Hour) + } + + _, err := s.db.ExecContext(ctx, ` + INSERT INTO project_invites (id, project_id, invited_email, role, token_hash, invited_by, expires_at, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8)`, + inv.ID, inv.ProjectID, inv.InvitedEmail, inv.Role, tokenHash, inv.InvitedBy, inv.ExpiresAt, inv.CreatedAt) + if err != nil { + return fmt.Errorf("create project invite: %w", err) + } + return nil +} + +func (s *PostgresProjectStore) AcceptInvite(ctx context.Context, tokenHash, userID, email, displayName string) (*domain.ProjectInvite, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + var inv domain.ProjectInvite + var acceptedAt sql.NullTime + err = tx.QueryRowContext(ctx, ` + SELECT id, project_id, invited_email, role, invited_by, expires_at, accepted_at, created_at + FROM project_invites WHERE token_hash = $1 FOR UPDATE`, tokenHash).Scan( + &inv.ID, &inv.ProjectID, &inv.InvitedEmail, &inv.Role, &inv.InvitedBy, &inv.ExpiresAt, &acceptedAt, &inv.CreatedAt) + if err != nil { + return nil, err + } + if acceptedAt.Valid { + return nil, fmt.Errorf("invite already accepted") + } + if time.Now().After(inv.ExpiresAt) { + return nil, fmt.Errorf("invite expired") + } + + now := time.Now().UTC() + if _, err := tx.ExecContext(ctx, + `UPDATE project_invites SET accepted_at=$2 WHERE id=$1`, inv.ID, now); err != nil { + return nil, fmt.Errorf("mark invite accepted: %w", err) + } + + if _, err := tx.ExecContext(ctx, ` + INSERT INTO project_members (project_id, user_id, email, display_name, role, invited_by, created_at) + VALUES ($1,$2,$3,$4,$5,$6,$7) + ON CONFLICT (project_id, user_id) DO UPDATE SET role=EXCLUDED.role`, + inv.ProjectID, userID, email, displayName, inv.Role, inv.InvitedBy, now); err != nil { + return nil, fmt.Errorf("add member on accept: %w", err) + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit accept invite: %w", err) + } + + inv.AcceptedAt = &now + return &inv, nil +} + +func (s *PostgresProjectStore) RevokeInvite(ctx context.Context, projectID, inviteID string) error { + res, err := s.db.ExecContext(ctx, + `DELETE FROM project_invites WHERE id=$1 AND project_id=$2 AND accepted_at IS NULL`, inviteID, projectID) + if err != nil { + return fmt.Errorf("revoke invite: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} + // ── Scanners ────────────────────────────────────────────────────────────────── type scanner interface { @@ -267,3 +482,27 @@ func scanConnection(s scanner) (*domain.Connection, error) { c.CreatedAt = c.CreatedAt.UTC() return &c, nil } + +func scanMember(s scanner) (*domain.ProjectMember, error) { + var m domain.ProjectMember + if err := s.Scan(&m.ProjectID, &m.UserID, &m.Email, &m.DisplayName, &m.Role, &m.InvitedBy, &m.CreatedAt); err != nil { + return nil, err + } + m.CreatedAt = m.CreatedAt.UTC() + return &m, nil +} + +func scanInvite(s scanner) (*domain.ProjectInvite, error) { + var inv domain.ProjectInvite + var acceptedAt sql.NullTime + if err := s.Scan(&inv.ID, &inv.ProjectID, &inv.InvitedEmail, &inv.Role, &inv.InvitedBy, &inv.ExpiresAt, &acceptedAt, &inv.CreatedAt); err != nil { + return nil, err + } + inv.ExpiresAt = inv.ExpiresAt.UTC() + inv.CreatedAt = inv.CreatedAt.UTC() + if acceptedAt.Valid { + t := acceptedAt.Time.UTC() + inv.AcceptedAt = &t + } + return &inv, nil +} diff --git a/internal/core/projects/domain/project.go b/internal/core/projects/domain/project.go index 57075c9..f7dec9a 100644 --- a/internal/core/projects/domain/project.go +++ b/internal/core/projects/domain/project.go @@ -2,6 +2,52 @@ package domain import "time" +// Role constants for project membership. +const ( + RoleOwner = "owner" + RoleMaintainer = "maintainer" + RoleDeveloper = "developer" + RoleViewer = "viewer" +) + +// ValidRoles is the ordered set of project roles from least to most privileged. +var ValidRoles = []string{RoleViewer, RoleDeveloper, RoleMaintainer, RoleOwner} + +// RoleRank returns a numeric rank for a role (higher = more privileged). +func RoleRank(role string) int { + for i, r := range ValidRoles { + if r == role { + return i + } + } + return -1 +} + +// ProjectMember is a user with a role in a project. +type ProjectMember struct { + ProjectID string `json:"project_id"` + UserID string `json:"user_id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + Role string `json:"role"` + InvitedBy string `json:"invited_by"` + CreatedAt time.Time `json:"created_at"` +} + +// ProjectInvite is a pending email invitation to a project. +type ProjectInvite struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + InvitedEmail string `json:"invited_email"` + Role string `json:"role"` + Token string `json:"token,omitempty"` + TokenHash string `json:"-"` + InvitedBy string `json:"invited_by"` + ExpiresAt time.Time `json:"expires_at"` + AcceptedAt *time.Time `json:"accepted_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + type Project struct { ID string `json:"id"` OrganizationID string `json:"organization_id"` diff --git a/internal/core/projects/ports/repository.go b/internal/core/projects/ports/repository.go index 8e5ffde..d67eb10 100644 --- a/internal/core/projects/ports/repository.go +++ b/internal/core/projects/ports/repository.go @@ -12,6 +12,7 @@ type ProjectRepository interface { FindByID(ctx context.Context, id string) (*domain.Project, error) FindBySlug(ctx context.Context, organizationID, slug string) (*domain.Project, error) ListByOrganization(ctx context.Context, organizationID string) ([]*domain.Project, error) + ListByMember(ctx context.Context, userID string) ([]*domain.Project, error) Save(ctx context.Context, project *domain.Project) error // Connections (workload links) @@ -23,4 +24,19 @@ type ProjectRepository interface { // Graph node positions (canvas layout) ListGraphNodes(ctx context.Context, projectID string) ([]*domain.GraphNode, error) UpsertGraphNode(ctx context.Context, node *domain.GraphNode) error + + // Project members + ListMembers(ctx context.Context, projectID string) ([]*domain.ProjectMember, error) + GetMember(ctx context.Context, projectID, userID string) (*domain.ProjectMember, error) + AddMember(ctx context.Context, member *domain.ProjectMember) error + UpdateMemberRole(ctx context.Context, projectID, userID, role string) error + RemoveMember(ctx context.Context, projectID, userID string) error + + // Project invites + ListInvites(ctx context.Context, projectID string) ([]*domain.ProjectInvite, error) + FindInviteByToken(ctx context.Context, tokenHash string) (*domain.ProjectInvite, error) + FindActiveInviteByEmail(ctx context.Context, projectID, email string) (*domain.ProjectInvite, error) + CreateInvite(ctx context.Context, invite *domain.ProjectInvite) error + AcceptInvite(ctx context.Context, tokenHash, userID, email, displayName string) (*domain.ProjectInvite, error) + RevokeInvite(ctx context.Context, projectID, inviteID string) error } diff --git a/internal/core/workloads/adapters/http/handler.go b/internal/core/workloads/adapters/http/handler.go index a7b77a4..d52b49d 100644 --- a/internal/core/workloads/adapters/http/handler.go +++ b/internal/core/workloads/adapters/http/handler.go @@ -1,7 +1,6 @@ package http import ( - "context" "database/sql" "encoding/json" "errors" @@ -14,6 +13,7 @@ import ( "github.com/go-chi/chi/v5" orgports "github.com/kleffio/platform/internal/core/organizations/ports" + projectdomain "github.com/kleffio/platform/internal/core/projects/domain" projectports "github.com/kleffio/platform/internal/core/projects/ports" "github.com/kleffio/platform/internal/core/workloads/application/commands" "github.com/kleffio/platform/internal/core/workloads/domain" @@ -62,7 +62,8 @@ func (h *Handler) RegisterInternalRoutes(r chi.Router) { func (h *Handler) provisionWorkload(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "projectID") orgID := h.callerOrganizationID(r) - if err := h.ensureProjectAccess(r.Context(), projectID, orgID); err != nil { + effectiveOrgID, err := h.ensureProjectAccess(r, projectID, orgID) + if err != nil { if errors.Is(err, sql.ErrNoRows) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) return @@ -70,6 +71,16 @@ func (h *Handler) provisionWorkload(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()}) return } + // Viewers may not create workloads — requires developer or above. + if h.projects != nil { + if claims, ok := middleware.ClaimsFromContext(r.Context()); ok && claims.Subject != "" { + member, memberErr := h.projects.GetMember(r.Context(), projectID, claims.Subject) + if memberErr == nil && projectdomain.RoleRank(member.Role) < projectdomain.RoleRank(projectdomain.RoleDeveloper) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden: requires developer role or higher"}) + return + } + } + } var req struct { OrganizationID string `json:"organization_id"` OwnerID string `json:"owner_id"` @@ -108,7 +119,7 @@ func (h *Handler) provisionWorkload(w http.ResponseWriter, r *http.Request) { initiatedBy = claims.Subject } res, err := h.provision.Handle(r.Context(), commands.ProvisionWorkloadCommand{ - OrganizationID: orgID, + OrganizationID: effectiveOrgID, ProjectID: projectID, OwnerID: req.OwnerID, ServerName: req.ServerName, @@ -137,7 +148,7 @@ func (h *Handler) provisionWorkload(w http.ResponseWriter, r *http.Request) { func (h *Handler) list(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "projectID") orgID := h.callerOrganizationID(r) - if err := h.ensureProjectAccess(r.Context(), projectID, orgID); err != nil { + if _, err := h.ensureProjectAccess(r, projectID, orgID); err != nil { if errors.Is(err, sql.ErrNoRows) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) return @@ -270,7 +281,8 @@ func (h *Handler) runAction(w http.ResponseWriter, r *http.Request, action queue projectID := chi.URLParam(r, "projectID") workloadID := chi.URLParam(r, "id") orgID := h.callerOrganizationID(r) - if err := h.ensureProjectAccess(r.Context(), projectID, orgID); err != nil { + effectiveOrgID, err := h.ensureProjectAccess(r, projectID, orgID) + if err != nil { if errors.Is(err, sql.ErrNoRows) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "project not found"}) return @@ -283,8 +295,8 @@ func (h *Handler) runAction(w http.ResponseWriter, r *http.Request, action queue initiatedBy = claims.Subject } - err := h.action.Handle(r.Context(), commands.WorkloadActionCommand{ - OrganizationID: orgID, + err = h.action.Handle(r.Context(), commands.WorkloadActionCommand{ + OrganizationID: effectiveOrgID, ProjectID: projectID, WorkloadID: workloadID, Action: action, @@ -362,16 +374,46 @@ func isValidWorkloadState(state domain.WorkloadState) bool { } } -func (h *Handler) ensureProjectAccess(ctx context.Context, projectID, organizationID string) error { - if organizationID == "" || h.projects == nil { - return nil +// ensureProjectAccess checks that the caller may access the given project. +// It returns the effective organization ID to forward to application commands: +// the caller's org when it matches the project, or "" when access was granted +// via explicit project membership (cross-org invite), which causes commands to +// skip their redundant org check. +func (h *Handler) ensureProjectAccess(r *http.Request, projectID, organizationID string) (string, error) { + if h.projects == nil { + return organizationID, nil } - project, err := h.projects.FindByID(ctx, projectID) + project, err := h.projects.FindByID(r.Context(), projectID) if err != nil { - return err - } - if project.OrganizationID != organizationID { - return fmt.Errorf("forbidden: project does not belong to caller organization") + return "", err + } + + h.logger.Debug("ensureProjectAccess", + "project_id", projectID, + "project_org", project.OrganizationID, + "caller_org", organizationID, + ) + + // Org matches — access granted, forward caller org normally. + if organizationID == "" || project.OrganizationID == organizationID { + return organizationID, nil + } + + // Org doesn't match, but caller may be an explicit project member + // (e.g. an invited user whose personal org differs from the project owner's org). + if claims, ok := middleware.ClaimsFromContext(r.Context()); ok && claims.Subject != "" { + _, memberErr := h.projects.GetMember(r.Context(), projectID, claims.Subject) + h.logger.Debug("ensureProjectAccess member check", + "project_id", projectID, + "subject", claims.Subject, + "member_err", memberErr, + ) + if memberErr == nil { + // Return "" so commands skip the org ownership check — access + // was already validated here via project membership. + return "", nil + } } - return nil + + return "", fmt.Errorf("forbidden: project does not belong to caller organization") } diff --git a/internal/database/migrations/010_project_members_invites.sql b/internal/database/migrations/010_project_members_invites.sql new file mode 100644 index 0000000..9e4e39e --- /dev/null +++ b/internal/database/migrations/010_project_members_invites.sql @@ -0,0 +1,27 @@ +-- 010_project_members_invites.sql +-- Enhances project_members with profile columns and adds project_invites table. + +ALTER TABLE project_members + ADD COLUMN IF NOT EXISTS email TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS display_name TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS invited_by TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_project_members_user_id ON project_members(user_id); + +-- Pending email invitations into a project. +CREATE TABLE IF NOT EXISTS project_invites ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + invited_email TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'developer', + token_hash TEXT NOT NULL UNIQUE, + invited_by TEXT NOT NULL DEFAULT '', + expires_at TIMESTAMPTZ NOT NULL, + accepted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT project_invites_role_check + CHECK (role IN ('owner', 'maintainer', 'developer', 'viewer')) +); + +CREATE INDEX IF NOT EXISTS idx_project_invites_project_id ON project_invites(project_id); +CREATE INDEX IF NOT EXISTS idx_project_invites_token ON project_invites(token_hash); diff --git a/packages/adapters/http/middleware.go b/packages/adapters/http/middleware.go index eba568a..92e82e4 100644 --- a/packages/adapters/http/middleware.go +++ b/packages/adapters/http/middleware.go @@ -123,3 +123,9 @@ func (rw *responseWriter) WriteHeader(status int) { rw.status = status rw.ResponseWriter.WriteHeader(status) } + +func (rw *responseWriter) Flush() { + if f, ok := rw.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} diff --git a/plugins.local.json b/plugins.local.json new file mode 100644 index 0000000..e69de29 From 9b1feb25fbddefdce16d7ebb7b24032e361edb77 Mon Sep 17 00:00:00 2001 From: jeremy Date: Mon, 20 Apr 2026 17:36:24 -0400 Subject: [PATCH 4/4] fix go linter --- internal/core/projects/adapters/persistence/store.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/core/projects/adapters/persistence/store.go b/internal/core/projects/adapters/persistence/store.go index f01b498..a1a1ba5 100644 --- a/internal/core/projects/adapters/persistence/store.go +++ b/internal/core/projects/adapters/persistence/store.go @@ -397,7 +397,7 @@ func (s *PostgresProjectStore) AcceptInvite(ctx context.Context, tokenHash, user if err != nil { return nil, fmt.Errorf("begin tx: %w", err) } - defer tx.Rollback() + defer tx.Rollback() //nolint:errcheck var inv domain.ProjectInvite var acceptedAt sql.NullTime