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 a1d04c4..07868d1 100644 --- a/internal/bootstrap/container.go +++ b/internal/bootstrap/container.go @@ -175,7 +175,7 @@ func NewContainer(cfg *Config, logger *slog.Logger) (*Container, error) { CatalogHandler: cataloghttp.NewHandler(catalogStore, logger), OrganizationsHandler: organizationshttp.NewHandler(orgStore, notificationSvc, logger), 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/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..a1a1ba5 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() //nolint:errcheck + + 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 3d4a4c7..f19bcbc 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 @@ -282,7 +293,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 @@ -295,8 +307,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, @@ -374,16 +386,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