Files
Primora/apps/backend/internal/services/platform_service.go
T
2026-04-10 12:03:31 +02:00

1826 lines
62 KiB
Go

package services
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/models"
"github.com/tdvorak/primora/apps/backend/internal/repositories"
"github.com/tdvorak/primora/apps/backend/internal/storage"
)
type PlatformService struct {
repo *repositories.CoreRepository
store *storage.LocalStore
mailer *Mailer
publicURL string
}
type BootstrapInput struct {
OrganizationName string `json:"organizationName" validate:"required,min=2"`
OrganizationSlug string `json:"organizationSlug" validate:"required,min=2"`
ProjectName string `json:"projectName" validate:"required,min=2"`
ProjectSlug string `json:"projectSlug" validate:"required,min=2"`
Description *string `json:"description"`
}
type CreateProjectInput struct {
Name string `json:"name" validate:"required,min=2"`
Slug string `json:"slug" validate:"required,min=2"`
Description *string `json:"description"`
}
type CreateOrganizationInput struct {
Name string `json:"name" validate:"required,min=2"`
Slug string `json:"slug" validate:"required,min=2"`
}
type UpdateOrganizationInput struct {
Name string `json:"name" validate:"required,min=2"`
Slug string `json:"slug" validate:"required,min=2"`
}
type UpdateProjectInput struct {
Name string `json:"name" validate:"required,min=2"`
Slug string `json:"slug" validate:"required,min=2"`
Description *string `json:"description"`
}
type CreateInvitationInput struct {
Email string `json:"email" validate:"required,email"`
OrgRole string `json:"orgRole" validate:"required,oneof=owner admin member"`
ProjectID *string `json:"projectId"`
ProjectRole *string `json:"projectRole" validate:"omitempty,oneof=admin developer viewer"`
RedirectURL *string `json:"redirectUrl"`
}
type CreateAPIKeyInput struct {
Name string `json:"name" validate:"required,min=2"`
}
type CreateBucketInput struct {
Name string `json:"name" validate:"required,min=2"`
Slug string `json:"slug" validate:"required,min=2"`
Visibility string `json:"visibility" validate:"required,oneof=private public"`
}
type UpdateBucketInput struct {
Name string `json:"name" validate:"required,min=2"`
Slug string `json:"slug" validate:"required,min=2"`
Visibility string `json:"visibility" validate:"required,oneof=private public"`
}
type UpdateOrganizationMemberRoleInput struct {
Role string `json:"role" validate:"required,oneof=owner admin member"`
}
type UpdateProjectMemberRoleInput struct {
Role string `json:"role" validate:"required,oneof=admin developer viewer"`
}
type AcceptInvitationInput struct {
Token string `json:"token" validate:"required"`
}
type UpdateObjectInput struct {
NewObjectKey string `json:"newObjectKey" validate:"required,min=1"`
DestinationBucketID *string `json:"destinationBucketId"`
}
type CopyObjectInput struct {
ObjectKey string `json:"objectKey" validate:"required,min=1"`
NewObjectKey string `json:"newObjectKey" validate:"required,min=1"`
DestinationBucketID *string `json:"destinationBucketId"`
}
type CreateCollectionInput struct {
Slug string `json:"slug" validate:"required,min=2"`
Name string `json:"name" validate:"required,min=2"`
Description *string `json:"description"`
Schema map[string]any `json:"schema"`
}
type UpdateCollectionInput struct {
Name *string `json:"name" validate:"omitempty,min=2"`
Description *string `json:"description"`
Schema map[string]any `json:"schema"`
}
type CreateDocumentInput struct {
Data map[string]any `json:"data" validate:"required"`
}
type UpdateDocumentInput struct {
Data map[string]any `json:"data" validate:"required"`
}
type PlatformSummary struct {
User CoreUserSummary `json:"user"`
Organizations []OrganizationWithProjects `json:"organizations"`
}
type CoreUserSummary struct {
ID uuid.UUID `json:"id"`
AuthSubject string `json:"authSubject"`
Email string `json:"email"`
Name string `json:"name"`
EmailVerified bool `json:"emailVerified"`
}
type OrganizationWithProjects struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
MembershipRole string `json:"membershipRole"`
Projects []ProjectSummary `json:"projects"`
}
type ProjectSummary struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description *string `json:"description,omitempty"`
MembershipRole *string `json:"membershipRole,omitempty"`
}
type ProjectOverview struct {
ProjectID uuid.UUID `json:"project_id"`
OrganizationID uuid.UUID `json:"organization_id"`
ProjectSlug string `json:"project_slug"`
ProjectName string `json:"project_name"`
MemberCount int64 `json:"member_count"`
ActiveAPIKeyCount int64 `json:"active_api_key_count"`
BucketCount int64 `json:"bucket_count"`
ObjectCount int64 `json:"object_count"`
ObjectBytesTotal int64 `json:"object_bytes_total"`
PendingInvitationCount int64 `json:"pending_invitation_count"`
AuditEvents24h int64 `json:"audit_events_24h"`
LastAuditAt *time.Time `json:"last_audit_at,omitempty"`
}
type OrganizationMemberSummary struct {
UserID uuid.UUID `json:"user_id"`
Email string `json:"email"`
Name string `json:"name"`
EmailVerified bool `json:"email_verified"`
Role string `json:"role"`
JoinedAt time.Time `json:"joined_at"`
}
type ProjectMemberSummary struct {
UserID uuid.UUID `json:"user_id"`
Email string `json:"email"`
Name string `json:"name"`
EmailVerified bool `json:"email_verified"`
Role string `json:"role"`
JoinedAt time.Time `json:"joined_at"`
}
type InvitationSummary struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
ProjectID *uuid.UUID `json:"project_id"`
ProjectName *string `json:"project_name"`
Email string `json:"email"`
OrgRole string `json:"org_role"`
ProjectRole *string `json:"project_role"`
ExpiresAt time.Time `json:"expires_at"`
AcceptedAt *time.Time `json:"accepted_at"`
InvitedByUserID *uuid.UUID `json:"invited_by_user_id"`
CreatedAt time.Time `json:"created_at"`
Status string `json:"status"`
}
func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string) *PlatformService {
return &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL}
}
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
if actor.UserID == nil {
return PlatformSummary{}, errors.New("user actor required")
}
user, err := s.repo.Queries().GetUserByAuthSubject(ctx, actor.AuthSubject)
if err != nil {
return PlatformSummary{}, err
}
orgs, err := s.repo.Queries().ListOrganizationsForUser(ctx, *actor.UserID)
if err != nil {
return PlatformSummary{}, err
}
response := PlatformSummary{
User: CoreUserSummary{
ID: user.ID,
AuthSubject: user.AuthSubject,
Email: user.Email,
Name: user.Name,
EmailVerified: user.EmailVerified,
},
Organizations: make([]OrganizationWithProjects, 0, len(orgs)),
}
for _, org := range orgs {
projects, err := s.repo.Queries().ListProjectsForOrganization(ctx, db.ListProjectsForOrganizationParams{
OrganizationID: org.ID,
UserID: *actor.UserID,
Btrim: "",
})
if err != nil {
return PlatformSummary{}, err
}
item := OrganizationWithProjects{
ID: org.ID,
Name: org.Name,
Slug: org.Slug,
MembershipRole: org.MembershipRole,
Projects: make([]ProjectSummary, 0, len(projects)),
}
for _, project := range projects {
var membershipRole *string
if project.MembershipRole.Valid {
role := string(project.MembershipRole.CoreProjectRole)
membershipRole = &role
}
item.Projects = append(item.Projects, ProjectSummary{
ID: project.ID,
Name: project.Name,
Slug: project.Slug,
Description: project.Description,
MembershipRole: membershipRole,
})
}
response.Organizations = append(response.Organizations, item)
}
return response, nil
}
func (s *PlatformService) Bootstrap(ctx context.Context, actor *models.Actor, input BootstrapInput, requestID string) (db.BootstrapOrganizationRow, error) {
if actor.UserID == nil {
return db.BootstrapOrganizationRow{}, errors.New("user actor required")
}
count, err := s.repo.CountOrganizations(ctx)
if err != nil {
return db.BootstrapOrganizationRow{}, err
}
if count > 0 {
return db.BootstrapOrganizationRow{}, fmt.Errorf("bootstrap already completed")
}
var result db.BootstrapOrganizationRow
err = s.repo.WithTx(ctx, func(q *db.Queries) error {
row, err := q.BootstrapOrganization(ctx, db.BootstrapOrganizationParams{
Slug: normalizeSlug(input.OrganizationSlug),
Name: strings.TrimSpace(input.OrganizationName),
UserID: *actor.UserID,
Slug_2: normalizeSlug(input.ProjectSlug),
Name_2: strings.TrimSpace(input.ProjectName),
Description: input.Description,
})
if err != nil {
return err
}
if _, err := q.CreateAuditLog(ctx, newAuditParams(row.OrganizationID, row.ProjectID, actor, requestID, "platform.bootstrap", "platform", "bootstrap", map[string]any{
"organizationSlug": row.OrganizationSlug,
"projectSlug": row.ProjectSlug,
})); err != nil {
return err
}
result = row
return nil
})
return result, err
}
func (s *PlatformService) ListOrganizations(ctx context.Context, actor *models.Actor) ([]db.ListOrganizationsForUserRow, error) {
if actor.UserID == nil {
return nil, errors.New("user actor required")
}
return s.repo.Queries().ListOrganizationsForUser(ctx, *actor.UserID)
}
func (s *PlatformService) CreateOrganization(ctx context.Context, actor *models.Actor, input CreateOrganizationInput, requestID string) (db.CoreOrganization, error) {
if actor.UserID == nil {
return db.CoreOrganization{}, errors.New("user actor required")
}
var organization db.CoreOrganization
err := s.repo.WithTx(ctx, func(q *db.Queries) error {
row, err := q.CreateOrganization(ctx, db.CreateOrganizationParams{
Slug: normalizeSlug(input.Slug),
Name: strings.TrimSpace(input.Name),
})
if err != nil {
return err
}
if _, err := q.AddOrganizationMember(ctx, db.AddOrganizationMemberParams{
OrganizationID: row.ID,
UserID: *actor.UserID,
Role: "owner",
}); err != nil {
return err
}
if _, err := q.CreateAuditLog(ctx, newAuditParams(row.ID, uuid.Nil, actor, requestID, "organization.created", "organization", row.ID.String(), map[string]any{
"slug": row.Slug,
})); err != nil {
return err
}
organization = row
return nil
})
return organization, err
}
func (s *PlatformService) UpdateOrganization(ctx context.Context, actor *models.Actor, organizationID uuid.UUID, input UpdateOrganizationInput, requestID string) (db.CoreOrganization, error) {
if err := s.requireOrganizationRole(ctx, actor, organizationID, "owner", "admin"); err != nil {
return db.CoreOrganization{}, err
}
updated, err := s.repo.Queries().UpdateOrganizationByID(ctx, db.UpdateOrganizationByIDParams{
ID: organizationID,
Slug: normalizeSlug(input.Slug),
Name: strings.TrimSpace(input.Name),
})
if err != nil {
return db.CoreOrganization{}, err
}
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(organizationID, uuid.Nil, actor, requestID, "organization.updated", "organization", organizationID.String(), map[string]any{
"slug": updated.Slug,
"name": updated.Name,
}))
return updated, nil
}
func (s *PlatformService) DeleteOrganization(ctx context.Context, actor *models.Actor, organizationID uuid.UUID, requestID string) error {
if err := s.requireOrganizationRole(ctx, actor, organizationID, "owner"); err != nil {
return err
}
bucketIDs, err := s.repo.Queries().ListBucketsForOrganization(ctx, organizationID)
if err != nil {
return err
}
if err := s.repo.WithTx(ctx, func(q *db.Queries) error {
deleted, err := q.DeleteOrganizationByID(ctx, organizationID)
if err != nil {
return err
}
if _, err := q.CreateAuditLog(ctx, newAuditParams(uuid.Nil, uuid.Nil, actor, requestID, "organization.deleted", "organization", deleted.ID.String(), map[string]any{
"slug": deleted.Slug,
"name": deleted.Name,
})); err != nil {
return err
}
return nil
}); err != nil {
return err
}
for _, bucketID := range bucketIDs {
_ = s.store.DeleteBucket(bucketID.String())
}
return nil
}
func (s *PlatformService) ListProjects(ctx context.Context, actor *models.Actor, organizationID uuid.UUID, query string) ([]db.ListProjectsForOrganizationRow, error) {
if err := s.requireOrganizationRole(ctx, actor, organizationID, "owner", "admin", "member"); err != nil {
return nil, err
}
if actor.UserID == nil {
return nil, errors.New("user actor required")
}
return s.repo.Queries().ListProjectsForOrganization(ctx, db.ListProjectsForOrganizationParams{
OrganizationID: organizationID,
UserID: *actor.UserID,
Btrim: query,
})
}
func (s *PlatformService) ListOrganizationMembers(ctx context.Context, actor *models.Actor, organizationID uuid.UUID) ([]OrganizationMemberSummary, error) {
if err := s.requireOrganizationRole(ctx, actor, organizationID, "owner", "admin", "member"); err != nil {
return nil, err
}
if actor.UserID == nil {
return nil, errors.New("user actor required")
}
rows, err := s.repo.Queries().ListOrganizationMembers(ctx, organizationID)
if err != nil {
return nil, err
}
items := make([]OrganizationMemberSummary, 0, len(rows))
for _, row := range rows {
items = append(items, OrganizationMemberSummary{
UserID: row.UserID,
Email: row.Email,
Name: row.Name,
EmailVerified: row.EmailVerified,
Role: row.Role,
JoinedAt: row.CreatedAt.Time,
})
}
return items, nil
}
func (s *PlatformService) UpdateOrganizationMemberRole(ctx context.Context, actor *models.Actor, organizationID, userID uuid.UUID, input UpdateOrganizationMemberRoleInput, requestID string) (OrganizationMemberSummary, error) {
if err := s.requireOrganizationRole(ctx, actor, organizationID, "owner"); err != nil {
return OrganizationMemberSummary{}, err
}
if actor.UserID == nil {
return OrganizationMemberSummary{}, errors.New("user actor required")
}
current, err := s.repo.Queries().GetOrganizationMembership(ctx, db.GetOrganizationMembershipParams{
OrganizationID: organizationID,
UserID: userID,
})
if err != nil {
return OrganizationMemberSummary{}, err
}
if current.Role == "owner" && input.Role != "owner" {
owners, err := s.repo.Queries().CountOrganizationOwners(ctx, organizationID)
if err != nil {
return OrganizationMemberSummary{}, err
}
if owners <= 1 {
return OrganizationMemberSummary{}, fmt.Errorf("organization must retain at least one owner")
}
}
updated, err := s.repo.Queries().UpdateOrganizationMemberRole(ctx, db.UpdateOrganizationMemberRoleParams{
OrganizationID: organizationID,
UserID: userID,
Role: input.Role,
})
if err != nil {
return OrganizationMemberSummary{}, err
}
user, err := s.repo.Queries().GetUserByID(ctx, userID)
if err != nil {
return OrganizationMemberSummary{}, err
}
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(organizationID, uuid.Nil, actor, requestID, "organization.member.role_updated", "organization_member", userID.String(), map[string]any{
"oldRole": current.Role,
"newRole": updated.Role,
}))
return OrganizationMemberSummary{
UserID: user.ID,
Email: user.Email,
Name: user.Name,
EmailVerified: user.EmailVerified,
Role: updated.Role,
JoinedAt: updated.CreatedAt.Time,
}, nil
}
func (s *PlatformService) RemoveOrganizationMember(ctx context.Context, actor *models.Actor, organizationID, userID uuid.UUID, requestID string) error {
if err := s.requireOrganizationRole(ctx, actor, organizationID, "owner"); err != nil {
return err
}
if actor.UserID == nil {
return errors.New("user actor required")
}
current, err := s.repo.Queries().GetOrganizationMembership(ctx, db.GetOrganizationMembershipParams{
OrganizationID: organizationID,
UserID: userID,
})
if err != nil {
return err
}
if current.Role == "owner" {
owners, err := s.repo.Queries().CountOrganizationOwners(ctx, organizationID)
if err != nil {
return err
}
if owners <= 1 {
return fmt.Errorf("organization must retain at least one owner")
}
}
return s.repo.WithTx(ctx, func(q *db.Queries) error {
if _, err := q.RemoveOrganizationMember(ctx, db.RemoveOrganizationMemberParams{
OrganizationID: organizationID,
UserID: userID,
}); err != nil {
return err
}
if err := q.RemoveProjectMembershipsForOrganizationUser(ctx, db.RemoveProjectMembershipsForOrganizationUserParams{
OrganizationID: organizationID,
UserID: userID,
}); err != nil {
return err
}
_, err := q.CreateAuditLog(ctx, newAuditParams(organizationID, uuid.Nil, actor, requestID, "organization.member.removed", "organization_member", userID.String(), map[string]any{
"oldRole": current.Role,
}))
return err
})
}
func (s *PlatformService) ListProjectMembers(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]ProjectMemberSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
if actor.UserID == nil {
return nil, errors.New("user actor required")
}
rows, err := s.repo.Queries().ListProjectMembers(ctx, projectID)
if err != nil {
return nil, err
}
items := make([]ProjectMemberSummary, 0, len(rows))
for _, row := range rows {
items = append(items, ProjectMemberSummary{
UserID: row.UserID,
Email: row.Email,
Name: row.Name,
EmailVerified: row.EmailVerified,
Role: row.Role,
JoinedAt: row.CreatedAt.Time,
})
}
return items, nil
}
func (s *PlatformService) GetProjectOverview(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (ProjectOverview, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return ProjectOverview{}, err
}
row, err := s.repo.Queries().GetProjectOverview(ctx, projectID)
if err != nil {
return ProjectOverview{}, err
}
return ProjectOverview{
ProjectID: row.ProjectID,
OrganizationID: row.OrganizationID,
ProjectSlug: row.ProjectSlug,
ProjectName: row.ProjectName,
MemberCount: row.MemberCount,
ActiveAPIKeyCount: row.ActiveApiKeyCount,
BucketCount: row.BucketCount,
ObjectCount: row.ObjectCount,
ObjectBytesTotal: row.ObjectBytesTotal,
PendingInvitationCount: row.PendingInvitationCount,
AuditEvents24h: row.AuditEvents24h,
LastAuditAt: projectOverviewLastAuditAt(row.LastAuditAt),
}, nil
}
func projectOverviewLastAuditAt(value pgtype.Timestamptz) *time.Time {
if !value.Valid {
return nil
}
timestamp := value.Time
return &timestamp
}
func (s *PlatformService) UpdateProjectMemberRole(ctx context.Context, actor *models.Actor, projectID, userID uuid.UUID, input UpdateProjectMemberRoleInput, requestID string) (ProjectMemberSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin"); err != nil {
return ProjectMemberSummary{}, err
}
if actor.UserID == nil {
return ProjectMemberSummary{}, errors.New("user actor required")
}
current, err := s.repo.Queries().GetProjectMembership(ctx, db.GetProjectMembershipParams{
ProjectID: projectID,
UserID: userID,
})
if err != nil {
return ProjectMemberSummary{}, err
}
if current.Role == "admin" && input.Role != "admin" {
adminCount, err := s.repo.Queries().CountProjectAdmins(ctx, projectID)
if err != nil {
return ProjectMemberSummary{}, err
}
if adminCount <= 1 {
return ProjectMemberSummary{}, fmt.Errorf("project must retain at least one admin")
}
}
updated, err := s.repo.Queries().UpdateProjectMemberRole(ctx, db.UpdateProjectMemberRoleParams{
ProjectID: projectID,
UserID: userID,
Role: input.Role,
})
if err != nil {
return ProjectMemberSummary{}, err
}
user, err := s.repo.Queries().GetUserByID(ctx, userID)
if err != nil {
return ProjectMemberSummary{}, err
}
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
if err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "project.member.role_updated", "project_member", userID.String(), map[string]any{
"oldRole": current.Role,
"newRole": updated.Role,
}))
}
return ProjectMemberSummary{
UserID: user.ID,
Email: user.Email,
Name: user.Name,
EmailVerified: user.EmailVerified,
Role: updated.Role,
JoinedAt: updated.CreatedAt.Time,
}, nil
}
func (s *PlatformService) RemoveProjectMember(ctx context.Context, actor *models.Actor, projectID, userID uuid.UUID, requestID string) error {
if err := s.requireProjectRole(ctx, actor, projectID, "admin"); err != nil {
return err
}
if actor.UserID == nil {
return errors.New("user actor required")
}
current, err := s.repo.Queries().GetProjectMembership(ctx, db.GetProjectMembershipParams{
ProjectID: projectID,
UserID: userID,
})
if err != nil {
return err
}
if current.Role == "admin" {
adminCount, err := s.repo.Queries().CountProjectAdmins(ctx, projectID)
if err != nil {
return err
}
if adminCount <= 1 {
return fmt.Errorf("project must retain at least one admin")
}
}
if _, err := s.repo.Queries().RemoveProjectMember(ctx, db.RemoveProjectMemberParams{
ProjectID: projectID,
UserID: userID,
}); err != nil {
return err
}
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
if err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "project.member.removed", "project_member", userID.String(), map[string]any{
"oldRole": current.Role,
}))
}
return nil
}
func (s *PlatformService) CreateProject(ctx context.Context, actor *models.Actor, organizationID uuid.UUID, input CreateProjectInput, requestID string) (db.CoreProject, error) {
if err := s.requireOrganizationRole(ctx, actor, organizationID, "owner", "admin"); err != nil {
return db.CoreProject{}, err
}
if actor.UserID == nil {
return db.CoreProject{}, errors.New("user actor required")
}
var project db.CoreProject
err := s.repo.WithTx(ctx, func(q *db.Queries) error {
row, err := q.CreateProject(ctx, db.CreateProjectParams{
OrganizationID: organizationID,
Slug: normalizeSlug(input.Slug),
Name: strings.TrimSpace(input.Name),
Description: input.Description,
})
if err != nil {
return err
}
if _, err := q.AddProjectMember(ctx, db.AddProjectMemberParams{
ProjectID: row.ID,
UserID: *actor.UserID,
Role: "admin",
}); err != nil {
return err
}
if _, err := q.CreateAuditLog(ctx, newAuditParams(organizationID, row.ID, actor, requestID, "project.created", "project", row.ID.String(), map[string]any{
"slug": row.Slug,
})); err != nil {
return err
}
project = row
return nil
})
return project, err
}
func (s *PlatformService) UpdateProject(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input UpdateProjectInput, requestID string) (db.CoreProject, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin"); err != nil {
return db.CoreProject{}, err
}
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
if err != nil {
return db.CoreProject{}, err
}
updated, err := s.repo.Queries().UpdateProjectByID(ctx, db.UpdateProjectByIDParams{
ID: projectID,
Slug: normalizeSlug(input.Slug),
Name: strings.TrimSpace(input.Name),
Description: input.Description,
})
if err != nil {
return db.CoreProject{}, err
}
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "project.updated", "project", projectID.String(), map[string]any{
"slug": updated.Slug,
"name": updated.Name,
}))
return updated, nil
}
func (s *PlatformService) DeleteProject(ctx context.Context, actor *models.Actor, projectID uuid.UUID, requestID string) error {
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
if err != nil {
return err
}
if err := s.requireOrganizationRole(ctx, actor, project.OrganizationID, "owner", "admin"); err != nil {
return err
}
buckets, err := s.repo.Queries().ListBucketsForProject(ctx, db.ListBucketsForProjectParams{
ProjectID: projectID,
Btrim: "",
})
if err != nil {
return err
}
if err := s.repo.WithTx(ctx, func(q *db.Queries) error {
if _, err := q.DeleteProjectByID(ctx, projectID); err != nil {
return err
}
if _, err := q.CreateAuditLog(ctx, newAuditParams(project.OrganizationID, uuid.Nil, actor, requestID, "project.deleted", "project", project.ID.String(), map[string]any{
"slug": project.Slug,
})); err != nil {
return err
}
return nil
}); err != nil {
return err
}
for _, bucket := range buckets {
_ = s.store.DeleteBucket(bucket.ID.String())
}
return nil
}
func (s *PlatformService) CreateInvitation(ctx context.Context, actor *models.Actor, organizationID uuid.UUID, input CreateInvitationInput, requestID string) (map[string]any, error) {
if err := s.requireOrganizationRole(ctx, actor, organizationID, "owner", "admin"); err != nil {
return nil, err
}
if actor.UserID == nil {
return nil, errors.New("user actor required")
}
var projectUUID pgtype.UUID
var projectRole db.NullCoreProjectRole
projectIDRaw := ""
if input.ProjectID != nil {
projectIDRaw = strings.TrimSpace(*input.ProjectID)
}
projectRoleRaw := ""
if input.ProjectRole != nil {
projectRoleRaw = strings.TrimSpace(*input.ProjectRole)
}
if projectIDRaw == "" && projectRoleRaw != "" {
return nil, fmt.Errorf("invalid invitation project scope")
}
if projectIDRaw != "" && projectRoleRaw == "" {
return nil, fmt.Errorf("project role is required when project id is provided")
}
if projectIDRaw != "" {
projectID, err := uuid.Parse(projectIDRaw)
if err != nil {
return nil, fmt.Errorf("parse project id: %w", err)
}
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
if err != nil {
return nil, err
}
if project.OrganizationID != organizationID {
return nil, fmt.Errorf("invalid invitation project scope")
}
projectUUID = pgtype.UUID{Bytes: projectID, Valid: true}
projectRole = db.NullCoreProjectRole{
CoreProjectRole: db.CoreProjectRole(projectRoleRaw),
Valid: true,
}
}
token, err := randomToken(32)
if err != nil {
return nil, fmt.Errorf("generate invitation token: %w", err)
}
tokenHash := digestString(token)
var invitation db.CoreProjectInvitation
err = s.repo.WithTx(ctx, func(q *db.Queries) error {
row, err := q.CreateInvitation(ctx, db.CreateInvitationParams{
OrganizationID: organizationID,
ProjectID: projectUUID,
Lower: strings.ToLower(strings.TrimSpace(input.Email)),
OrgRole: input.OrgRole,
ProjectRole: projectRole,
TokenHash: tokenHash,
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(72 * time.Hour), Valid: true},
InvitedByUserID: pgtype.UUID{Bytes: *actor.UserID, Valid: true},
})
if err != nil {
return err
}
if _, err := q.CreateAuditLog(ctx, newAuditParams(organizationID, pgUUIDOrNil(projectUUID), actor, requestID, "invitation.created", "invitation", row.ID.String(), map[string]any{
"email": row.Email,
})); err != nil {
return err
}
invitation = row
return nil
})
if err != nil {
return nil, err
}
baseInviteURL := s.publicURL + "/invite"
if input.RedirectURL != nil && *input.RedirectURL != "" {
baseInviteURL = *input.RedirectURL
}
inviteURL, err := url.JoinPath(strings.TrimRight(baseInviteURL, "/"), token)
if err != nil {
inviteURL = strings.TrimRight(baseInviteURL, "/") + "/" + token
}
if err := s.mailer.SendInvitation(ctx, invitation.Email, "Primora", inviteURL); err != nil {
return nil, err
}
return map[string]any{
"id": invitation.ID,
"email": invitation.Email,
"expiresAt": invitation.ExpiresAt.Time,
}, nil
}
func (s *PlatformService) ListInvitations(ctx context.Context, actor *models.Actor, organizationID uuid.UUID) ([]InvitationSummary, error) {
if err := s.requireOrganizationRole(ctx, actor, organizationID, "owner", "admin"); err != nil {
return nil, err
}
rows, err := s.repo.Queries().ListInvitationsForOrganization(ctx, organizationID)
if err != nil {
return nil, err
}
now := time.Now()
items := make([]InvitationSummary, 0, len(rows))
for _, row := range rows {
var projectID *uuid.UUID
if row.ProjectID.Valid {
id := uuid.UUID(row.ProjectID.Bytes)
projectID = &id
}
var projectRole *string
if row.ProjectRole.Valid {
role := string(row.ProjectRole.CoreProjectRole)
projectRole = &role
}
var acceptedAt *time.Time
if row.AcceptedAt.Valid {
accepted := row.AcceptedAt.Time
acceptedAt = &accepted
}
var invitedByUserID *uuid.UUID
if row.InvitedByUserID.Valid {
userID := uuid.UUID(row.InvitedByUserID.Bytes)
invitedByUserID = &userID
}
status := "pending"
if acceptedAt != nil {
status = "accepted"
} else if row.ExpiresAt.Valid && row.ExpiresAt.Time.Before(now) {
status = "expired"
}
items = append(items, InvitationSummary{
ID: row.ID,
OrganizationID: row.OrganizationID,
ProjectID: projectID,
ProjectName: row.ProjectName,
Email: row.Email,
OrgRole: row.OrgRole,
ProjectRole: projectRole,
ExpiresAt: row.ExpiresAt.Time,
AcceptedAt: acceptedAt,
InvitedByUserID: invitedByUserID,
CreatedAt: row.CreatedAt.Time,
Status: status,
})
}
return items, nil
}
func (s *PlatformService) RevokeInvitation(ctx context.Context, actor *models.Actor, organizationID, invitationID uuid.UUID, requestID string) error {
if err := s.requireOrganizationRole(ctx, actor, organizationID, "owner", "admin"); err != nil {
return err
}
invitation, err := s.repo.Queries().GetInvitationByIDForOrganization(ctx, db.GetInvitationByIDForOrganizationParams{
OrganizationID: organizationID,
ID: invitationID,
})
if err != nil {
return err
}
if invitation.AcceptedAt.Valid {
return fmt.Errorf("invitation already accepted")
}
deleted, err := s.repo.Queries().DeletePendingInvitationByIDForOrganization(ctx, db.DeletePendingInvitationByIDForOrganizationParams{
OrganizationID: organizationID,
ID: invitationID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("invitation already accepted")
}
return err
}
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(organizationID, pgUUIDOrNil(deleted.ProjectID), actor, requestID, "invitation.revoked", "invitation", invitationID.String(), map[string]any{
"email": deleted.Email,
}))
return nil
}
func (s *PlatformService) AcceptInvitation(ctx context.Context, actor *models.Actor, input AcceptInvitationInput, requestID string) error {
if actor.UserID == nil {
return errors.New("user actor required")
}
tokenHash := digestString(input.Token)
return s.repo.WithTx(ctx, func(q *db.Queries) error {
invite, err := q.GetInvitationByTokenHash(ctx, tokenHash)
if err != nil {
return err
}
if invite.AcceptedAt.Valid {
return fmt.Errorf("invitation already accepted")
}
if invite.ExpiresAt.Time.Before(time.Now()) {
return fmt.Errorf("invitation expired")
}
if !strings.EqualFold(invite.Email, actor.Email) {
return fmt.Errorf("invitation email does not match authenticated user")
}
if _, err := q.AddOrganizationMember(ctx, db.AddOrganizationMemberParams{
OrganizationID: invite.OrganizationID,
UserID: *actor.UserID,
Role: invite.OrgRole,
}); err != nil {
return err
}
if invite.ProjectID.Valid && invite.ProjectRole.Valid {
project, err := q.GetProjectByID(ctx, invite.ProjectID.Bytes)
if err != nil {
return err
}
if project.OrganizationID != invite.OrganizationID {
return fmt.Errorf("invalid invitation project scope")
}
if _, err := q.AddProjectMember(ctx, db.AddProjectMemberParams{
ProjectID: invite.ProjectID.Bytes,
UserID: *actor.UserID,
Role: string(invite.ProjectRole.CoreProjectRole),
}); err != nil {
return err
}
}
if _, err := q.MarkInvitationAccepted(ctx, invite.ID); err != nil {
return err
}
_, err = q.CreateAuditLog(ctx, newAuditParams(invite.OrganizationID, pgUUIDOrNil(invite.ProjectID), actor, requestID, "invitation.accepted", "invitation", invite.ID.String(), map[string]any{
"email": invite.Email,
}))
return err
})
}
func (s *PlatformService) ListAPIKeys(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]db.CoreApiKey, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin"); err != nil {
return nil, err
}
return s.repo.Queries().ListAPIKeysForProject(ctx, projectID)
}
func (s *PlatformService) CreateAPIKey(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateAPIKeyInput, requestID string) (map[string]any, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin"); err != nil {
return nil, err
}
if actor.UserID == nil {
return nil, errors.New("user actor required")
}
prefixSuffix, err := randomString(8)
if err != nil {
return nil, fmt.Errorf("generate api key prefix: %w", err)
}
keySuffix, err := randomString(24)
if err != nil {
return nil, fmt.Errorf("generate api key secret: %w", err)
}
prefix := "prm_" + prefixSuffix
rawKey := prefix + "_" + keySuffix
secretHash := sha256.Sum256([]byte(rawKey))
row, err := s.repo.Queries().CreateAPIKey(ctx, db.CreateAPIKeyParams{
ProjectID: projectID,
Name: strings.TrimSpace(input.Name),
Prefix: prefix,
SecretHash: secretHash[:],
CreatedByUserID: pgtype.UUID{Bytes: *actor.UserID, Valid: true},
})
if err != nil {
return nil, err
}
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
if err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "api_key.created", "api_key", row.ID.String(), map[string]any{
"name": row.Name,
}))
}
return map[string]any{
"id": row.ID,
"prefix": row.Prefix,
"secret": rawKey,
"name": row.Name,
}, nil
}
func (s *PlatformService) RevokeAPIKey(ctx context.Context, actor *models.Actor, projectID, apiKeyID uuid.UUID, requestID string) error {
if err := s.requireProjectRole(ctx, actor, projectID, "admin"); err != nil {
return err
}
if actor.UserID == nil {
return errors.New("user actor required")
}
key, err := s.repo.Queries().GetAPIKeyByIDForProject(ctx, db.GetAPIKeyByIDForProjectParams{
ProjectID: projectID,
ID: apiKeyID,
})
if err != nil {
return err
}
if key.RevokedAt.Valid {
return fmt.Errorf("api key already revoked")
}
if _, err := s.repo.Queries().RevokeAPIKey(ctx, db.RevokeAPIKeyParams{
ProjectID: projectID,
ID: apiKeyID,
}); err != nil {
return err
}
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
if err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "api_key.revoked", "api_key", apiKeyID.String(), map[string]any{}))
}
return nil
}
func (s *PlatformService) ListBuckets(ctx context.Context, actor *models.Actor, projectID uuid.UUID, query string) ([]db.CoreBucket, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
return s.repo.Queries().ListBucketsForProject(ctx, db.ListBucketsForProjectParams{
ProjectID: projectID,
Btrim: query,
})
}
func (s *PlatformService) CreateBucket(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateBucketInput, requestID string) (db.CoreBucket, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return db.CoreBucket{}, err
}
var createdBy pgtype.UUID
if actor.UserID != nil {
createdBy = pgtype.UUID{Bytes: *actor.UserID, Valid: true}
}
row, err := s.repo.Queries().CreateBucket(ctx, db.CreateBucketParams{
ProjectID: projectID,
Slug: normalizeSlug(input.Slug),
Name: strings.TrimSpace(input.Name),
Visibility: input.Visibility,
CreatedByUserID: createdBy,
})
if err != nil {
return db.CoreBucket{}, err
}
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
if err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "bucket.created", "bucket", row.ID.String(), map[string]any{
"slug": row.Slug,
}))
}
return row, nil
}
func (s *PlatformService) UpdateBucket(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, input UpdateBucketInput, requestID string) (db.CoreBucket, error) {
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
if err != nil {
return db.CoreBucket{}, err
}
if err := s.requireProjectRole(ctx, actor, bucket.ProjectID, "admin", "developer"); err != nil {
return db.CoreBucket{}, err
}
updated, err := s.repo.Queries().UpdateBucketByID(ctx, db.UpdateBucketByIDParams{
ID: bucketID,
Slug: normalizeSlug(input.Slug),
Name: strings.TrimSpace(input.Name),
Visibility: input.Visibility,
})
if err != nil {
return db.CoreBucket{}, err
}
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(bucket.OrganizationID, bucket.ProjectID, actor, requestID, "bucket.updated", "bucket", bucketID.String(), map[string]any{
"slug": updated.Slug,
"name": updated.Name,
"visibility": updated.Visibility,
}))
return updated, nil
}
func (s *PlatformService) DeleteBucket(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, requestID string) error {
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
if err != nil {
return err
}
if err := s.requireProjectRole(ctx, actor, bucket.ProjectID, "admin", "developer"); err != nil {
return err
}
if err := s.repo.WithTx(ctx, func(q *db.Queries) error {
if _, err := q.DeleteBucketByID(ctx, bucketID); err != nil {
return err
}
if _, err := q.CreateAuditLog(ctx, newAuditParams(bucket.OrganizationID, bucket.ProjectID, actor, requestID, "bucket.deleted", "bucket", bucketID.String(), map[string]any{
"slug": bucket.Slug,
})); err != nil {
return err
}
return nil
}); err != nil {
return err
}
_ = s.store.DeleteBucket(bucketID.String())
return nil
}
func (s *PlatformService) UploadObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey, contentType string, reader io.Reader, requestID string) (db.CoreBucketObject, error) {
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
if err != nil {
return db.CoreBucketObject{}, err
}
if err := s.requireBucketWrite(ctx, actor, bucket); err != nil {
return db.CoreBucketObject{}, err
}
uploader := pgtype.UUID{}
if actor != nil && actor.UserID != nil {
uploader = pgtype.UUID{Bytes: *actor.UserID, Valid: true}
}
stored, err := s.store.Put(ctx, bucket.ID.String(), objectKey, reader)
if err != nil {
return db.CoreBucketObject{}, err
}
row, err := s.repo.Queries().CreateBucketObject(ctx, db.CreateBucketObjectParams{
BucketID: bucket.ID,
ObjectKey: objectKey,
ContentType: contentType,
SizeBytes: stored.SizeBytes,
ChecksumSha256: stored.SHA256Digest,
StoragePath: filepath.Clean(stored.Path),
UploadedByUserID: uploader,
})
if err != nil {
return db.CoreBucketObject{}, err
}
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(bucket.OrganizationID, bucket.ProjectID, actor, requestID, "object.uploaded", "bucket_object", row.ID.String(), map[string]any{
"bucketId": bucket.ID.String(),
"key": objectKey,
}))
return row, nil
}
func (s *PlatformService) ListObjects(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, query string, limit, offset int32) ([]db.CoreBucketObject, error) {
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
if err != nil {
return nil, err
}
if err := s.requireBucketRead(ctx, actor, bucket); err != nil {
return nil, err
}
return s.repo.Queries().ListBucketObjects(ctx, db.ListBucketObjectsParams{
BucketID: bucketID,
Btrim: query,
Limit: limit,
Offset: offset,
})
}
func (s *PlatformService) CountObjects(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, query string) (int64, error) {
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
if err != nil {
return 0, err
}
if err := s.requireBucketRead(ctx, actor, bucket); err != nil {
return 0, err
}
return s.repo.Queries().CountBucketObjects(ctx, db.CountBucketObjectsParams{
BucketID: bucketID,
Btrim: query,
})
}
func (s *PlatformService) GetObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey string) (db.CoreBucketObject, io.ReadCloser, error) {
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
if err != nil {
return db.CoreBucketObject{}, nil, err
}
if err := s.requireBucketRead(ctx, actor, bucket); err != nil {
return db.CoreBucketObject{}, nil, err
}
object, err := s.repo.Queries().GetBucketObjectByKey(ctx, db.GetBucketObjectByKeyParams{
BucketID: bucketID,
ObjectKey: objectKey,
})
if err != nil {
return db.CoreBucketObject{}, nil, err
}
file, _, err := s.store.Open(bucketID.String(), objectKey)
if err != nil {
return db.CoreBucketObject{}, nil, err
}
return object, file, nil
}
func (s *PlatformService) UpdateObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey string, input UpdateObjectInput, requestID string) (db.CoreBucketObject, error) {
sourceBucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
if err != nil {
return db.CoreBucketObject{}, err
}
if err := s.requireBucketWrite(ctx, actor, sourceBucket); err != nil {
return db.CoreBucketObject{}, err
}
destinationBucket := sourceBucket
destinationBucketID := sourceBucket.ID
if input.DestinationBucketID != nil {
destinationID := strings.TrimSpace(*input.DestinationBucketID)
if destinationID != "" {
parsedDestinationID, err := uuid.Parse(destinationID)
if err != nil {
return db.CoreBucketObject{}, fmt.Errorf("invalid destination bucket id")
}
targetBucket, err := s.repo.Queries().GetBucketByID(ctx, parsedDestinationID)
if err != nil {
return db.CoreBucketObject{}, err
}
if targetBucket.ProjectID != sourceBucket.ProjectID {
return db.CoreBucketObject{}, fmt.Errorf("invalid move scope: destination bucket must belong to the same project")
}
if err := s.requireBucketWrite(ctx, actor, targetBucket); err != nil {
return db.CoreBucketObject{}, err
}
destinationBucket = targetBucket
destinationBucketID = parsedDestinationID
}
}
newObjectKey := strings.TrimSpace(input.NewObjectKey)
if newObjectKey == "" {
return db.CoreBucketObject{}, fmt.Errorf("new object key is required")
}
oldObject, err := s.repo.Queries().GetBucketObjectByKey(ctx, db.GetBucketObjectByKeyParams{
BucketID: bucketID,
ObjectKey: objectKey,
})
if err != nil {
return db.CoreBucketObject{}, err
}
if destinationBucketID == sourceBucket.ID && newObjectKey == oldObject.ObjectKey {
return oldObject, nil
}
newStoragePath, err := s.store.MoveBetweenBuckets(sourceBucket.ID.String(), destinationBucketID.String(), objectKey, newObjectKey)
if err != nil {
return db.CoreBucketObject{}, err
}
updated, err := s.repo.Queries().MoveBucketObject(ctx, db.MoveBucketObjectParams{
BucketID: bucketID,
ObjectKey: objectKey,
BucketID_2: destinationBucketID,
ObjectKey_2: newObjectKey,
StoragePath: filepath.Clean(newStoragePath),
})
if err != nil {
_, _ = s.store.MoveBetweenBuckets(destinationBucketID.String(), sourceBucket.ID.String(), newObjectKey, objectKey)
return db.CoreBucketObject{}, err
}
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(sourceBucket.OrganizationID, sourceBucket.ProjectID, actor, requestID, "object.moved", "bucket_object", updated.ID.String(), map[string]any{
"sourceBucketId": sourceBucket.ID.String(),
"destinationBucketId": destinationBucket.ID.String(),
"oldObjectKey": oldObject.ObjectKey,
"newObjectKey": updated.ObjectKey,
}))
return updated, nil
}
func (s *PlatformService) CopyObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, input CopyObjectInput, requestID string) (db.CoreBucketObject, error) {
sourceBucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
if err != nil {
return db.CoreBucketObject{}, err
}
if err := s.requireBucketRead(ctx, actor, sourceBucket); err != nil {
return db.CoreBucketObject{}, err
}
sourceObjectKey := strings.TrimSpace(input.ObjectKey)
if sourceObjectKey == "" {
return db.CoreBucketObject{}, fmt.Errorf("object key is required")
}
newObjectKey := strings.TrimSpace(input.NewObjectKey)
if newObjectKey == "" {
return db.CoreBucketObject{}, fmt.Errorf("new object key is required")
}
sourceObject, err := s.repo.Queries().GetBucketObjectByKey(ctx, db.GetBucketObjectByKeyParams{
BucketID: sourceBucket.ID,
ObjectKey: sourceObjectKey,
})
if err != nil {
return db.CoreBucketObject{}, err
}
destinationBucket := sourceBucket
destinationBucketID := sourceBucket.ID
if input.DestinationBucketID != nil {
destinationID := strings.TrimSpace(*input.DestinationBucketID)
if destinationID != "" {
parsedDestinationID, err := uuid.Parse(destinationID)
if err != nil {
return db.CoreBucketObject{}, fmt.Errorf("invalid destination bucket id")
}
targetBucket, err := s.repo.Queries().GetBucketByID(ctx, parsedDestinationID)
if err != nil {
return db.CoreBucketObject{}, err
}
if targetBucket.ProjectID != sourceBucket.ProjectID {
return db.CoreBucketObject{}, fmt.Errorf("invalid copy scope: destination bucket must belong to the same project")
}
destinationBucket = targetBucket
destinationBucketID = parsedDestinationID
}
}
if err := s.requireBucketWrite(ctx, actor, destinationBucket); err != nil {
return db.CoreBucketObject{}, err
}
sourceFile, _, err := s.store.Open(sourceBucket.ID.String(), sourceObjectKey)
if err != nil {
return db.CoreBucketObject{}, err
}
defer sourceFile.Close()
stored, err := s.store.Put(ctx, destinationBucketID.String(), newObjectKey, sourceFile)
if err != nil {
return db.CoreBucketObject{}, err
}
uploader := pgtype.UUID{}
if actor != nil && actor.UserID != nil {
uploader = pgtype.UUID{Bytes: *actor.UserID, Valid: true}
}
created, err := s.repo.Queries().CreateBucketObject(ctx, db.CreateBucketObjectParams{
BucketID: destinationBucketID,
ObjectKey: newObjectKey,
ContentType: sourceObject.ContentType,
SizeBytes: stored.SizeBytes,
ChecksumSha256: stored.SHA256Digest,
StoragePath: filepath.Clean(stored.Path),
UploadedByUserID: uploader,
})
if err != nil {
_ = s.store.Delete(destinationBucketID.String(), newObjectKey)
return db.CoreBucketObject{}, err
}
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(sourceBucket.OrganizationID, sourceBucket.ProjectID, actor, requestID, "object.copied", "bucket_object", created.ID.String(), map[string]any{
"sourceBucketId": sourceBucket.ID.String(),
"destinationBucketId": destinationBucket.ID.String(),
"sourceObjectKey": sourceObjectKey,
"newObjectKey": created.ObjectKey,
}))
return created, nil
}
func (s *PlatformService) DeleteObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey, requestID string) error {
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
if err != nil {
return err
}
if err := s.requireBucketWrite(ctx, actor, bucket); err != nil {
return err
}
object, err := s.repo.Queries().DeleteBucketObjectByKey(ctx, db.DeleteBucketObjectByKeyParams{
BucketID: bucketID,
ObjectKey: objectKey,
})
if err != nil {
return err
}
if err := s.store.Delete(bucketID.String(), objectKey); err != nil {
return err
}
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(bucket.OrganizationID, bucket.ProjectID, actor, requestID, "object.deleted", "bucket_object", object.ID.String(), map[string]any{
"bucketId": bucketID.String(),
"key": objectKey,
}))
return nil
}
func (s *PlatformService) ListAuditLogs(
ctx context.Context,
actor *models.Actor,
projectID uuid.UUID,
query string,
action string,
limit,
offset int32,
) ([]db.CoreAuditLog, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin"); err != nil {
return nil, err
}
return s.repo.Queries().ListAuditLogsForProject(ctx, db.ListAuditLogsForProjectParams{
ProjectID: pgtype.UUID{Bytes: projectID, Valid: true},
Btrim: query,
Btrim_2: action,
Limit: limit,
Offset: offset,
})
}
func (s *PlatformService) CountAuditLogs(
ctx context.Context,
actor *models.Actor,
projectID uuid.UUID,
query string,
action string,
) (int64, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin"); err != nil {
return 0, err
}
return s.repo.Queries().CountAuditLogsForProject(ctx, db.CountAuditLogsForProjectParams{
ProjectID: pgtype.UUID{Bytes: projectID, Valid: true},
Btrim: query,
Btrim_2: action,
})
}
func (s *PlatformService) ListCollections(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]db.CoreCollection, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
return s.repo.Queries().ListCollections(ctx, projectID)
}
func (s *PlatformService) GetCollection(ctx context.Context, actor *models.Actor, projectID, collectionID uuid.UUID) (db.CoreCollection, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return db.CoreCollection{}, err
}
collection, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
if err != nil {
return db.CoreCollection{}, err
}
if collection.ProjectID != projectID {
return db.CoreCollection{}, fmt.Errorf("collection access denied")
}
return collection, nil
}
func (s *PlatformService) CreateCollection(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateCollectionInput, requestID string) (db.CoreCollection, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return db.CoreCollection{}, err
}
var createdBy pgtype.UUID
if actor.UserID != nil {
createdBy = pgtype.UUID{Bytes: *actor.UserID, Valid: true}
}
schemaBytes, _ := json.Marshal(input.Schema)
row, err := s.repo.Queries().CreateCollection(ctx, db.CreateCollectionParams{
ProjectID: projectID,
Slug: normalizeSlug(input.Slug),
Name: strings.TrimSpace(input.Name),
Description: input.Description,
Schema: schemaBytes,
CreatedByUserID: createdBy,
})
if err != nil {
return db.CoreCollection{}, err
}
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
if err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "collection.created", "collection", row.ID.String(), map[string]any{
"slug": row.Slug,
}))
}
return row, nil
}
func (s *PlatformService) UpdateCollection(ctx context.Context, actor *models.Actor, projectID, collectionID uuid.UUID, input UpdateCollectionInput, requestID string) (db.CoreCollection, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return db.CoreCollection{}, err
}
current, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
if err != nil {
return db.CoreCollection{}, err
}
if current.ProjectID != projectID {
return db.CoreCollection{}, fmt.Errorf("collection access denied")
}
name := current.Name
if input.Name != nil {
name = strings.TrimSpace(*input.Name)
}
description := current.Description
if input.Description != nil {
description = input.Description
}
schemaBytes := current.Schema
if input.Schema != nil {
schemaBytes, _ = json.Marshal(input.Schema)
}
updated, err := s.repo.Queries().UpdateCollection(ctx, db.UpdateCollectionParams{
ID: collectionID,
ProjectID: projectID,
Name: name,
Description: description,
Schema: schemaBytes,
})
if err != nil {
return db.CoreCollection{}, err
}
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "collection.updated", "collection", collectionID.String(), map[string]any{
"name": updated.Name,
}))
return updated, nil
}
func (s *PlatformService) DeleteCollection(ctx context.Context, actor *models.Actor, projectID, collectionID uuid.UUID, requestID string) error {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return err
}
current, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
if err != nil {
return err
}
if current.ProjectID != projectID {
return fmt.Errorf("collection access denied")
}
if err := s.repo.Queries().DeleteCollection(ctx, db.DeleteCollectionParams{
ID: collectionID,
ProjectID: projectID,
}); err != nil {
return err
}
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "collection.deleted", "collection", collectionID.String(), map[string]any{
"slug": current.Slug,
}))
return nil
}
func (s *PlatformService) ListDocuments(ctx context.Context, actor *models.Actor, collectionID uuid.UUID, limit, offset int32) ([]db.CoreDocument, error) {
collection, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
if err != nil {
return nil, err
}
if err := s.requireProjectRole(ctx, actor, collection.ProjectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
return s.repo.Queries().ListDocuments(ctx, db.ListDocumentsParams{
CollectionID: collectionID,
Limit: limit,
Offset: offset,
})
}
func (s *PlatformService) CountDocuments(ctx context.Context, actor *models.Actor, collectionID uuid.UUID) (int64, error) {
collection, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
if err != nil {
return 0, err
}
if err := s.requireProjectRole(ctx, actor, collection.ProjectID, "admin", "developer", "viewer"); err != nil {
return 0, err
}
return s.repo.Queries().CountDocuments(ctx, collectionID)
}
func (s *PlatformService) GetDocument(ctx context.Context, actor *models.Actor, collectionID, documentID uuid.UUID) (db.CoreDocument, error) {
collection, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
if err != nil {
return db.CoreDocument{}, err
}
if err := s.requireProjectRole(ctx, actor, collection.ProjectID, "admin", "developer", "viewer"); err != nil {
return db.CoreDocument{}, err
}
return s.repo.Queries().GetDocumentByID(ctx, db.GetDocumentByIDParams{
ID: documentID,
CollectionID: collectionID,
})
}
func (s *PlatformService) CreateDocument(ctx context.Context, actor *models.Actor, collectionID uuid.UUID, input CreateDocumentInput, requestID string) (db.CoreDocument, error) {
collection, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
if err != nil {
return db.CoreDocument{}, err
}
if err := s.requireProjectRole(ctx, actor, collection.ProjectID, "admin", "developer"); err != nil {
return db.CoreDocument{}, err
}
var createdBy pgtype.UUID
if actor.UserID != nil {
createdBy = pgtype.UUID{Bytes: *actor.UserID, Valid: true}
}
dataBytes, _ := json.Marshal(input.Data)
row, err := s.repo.Queries().CreateDocument(ctx, db.CreateDocumentParams{
CollectionID: collectionID,
Data: dataBytes,
CreatedByUserID: createdBy,
})
if err != nil {
return db.CoreDocument{}, err
}
project, _ := s.repo.Queries().GetProjectByID(ctx, collection.ProjectID)
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.created", "document", row.ID.String(), map[string]any{
"collectionId": collectionID.String(),
}))
return row, nil
}
func (s *PlatformService) UpdateDocument(ctx context.Context, actor *models.Actor, collectionID, documentID uuid.UUID, input UpdateDocumentInput, requestID string) (db.CoreDocument, error) {
collection, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
if err != nil {
return db.CoreDocument{}, err
}
if err := s.requireProjectRole(ctx, actor, collection.ProjectID, "admin", "developer"); err != nil {
return db.CoreDocument{}, err
}
dataBytes, _ := json.Marshal(input.Data)
row, err := s.repo.Queries().UpdateDocument(ctx, db.UpdateDocumentParams{
ID: documentID,
CollectionID: collectionID,
Data: dataBytes,
})
if err != nil {
return db.CoreDocument{}, err
}
project, _ := s.repo.Queries().GetProjectByID(ctx, collection.ProjectID)
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.updated", "document", documentID.String(), map[string]any{
"collectionId": collectionID.String(),
}))
return row, nil
}
func (s *PlatformService) DeleteDocument(ctx context.Context, actor *models.Actor, collectionID, documentID uuid.UUID, requestID string) error {
collection, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
if err != nil {
return err
}
if err := s.requireProjectRole(ctx, actor, collection.ProjectID, "admin", "developer"); err != nil {
return err
}
if err := s.repo.Queries().DeleteDocument(ctx, db.DeleteDocumentParams{
ID: documentID,
CollectionID: collectionID,
}); err != nil {
return err
}
project, _ := s.repo.Queries().GetProjectByID(ctx, collection.ProjectID)
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.deleted", "document", documentID.String(), map[string]any{
"collectionId": collectionID.String(),
}))
return nil
}
func (s *PlatformService) requireOrganizationRole(ctx context.Context, actor *models.Actor, organizationID uuid.UUID, allowedRoles ...string) error {
if actor == nil {
return errors.New("authentication required")
}
if actor.IsAPIKey() {
if actor.OrganizationID != nil && *actor.OrganizationID == organizationID {
return nil
}
return errors.New("organization access denied")
}
if actor.UserID == nil {
return errors.New("user actor required")
}
membership, err := s.repo.Queries().GetOrganizationMembership(ctx, db.GetOrganizationMembershipParams{
OrganizationID: organizationID,
UserID: *actor.UserID,
})
if err != nil {
return err
}
if !containsRole(membership.Role, allowedRoles...) {
return fmt.Errorf("organization role %s is insufficient", membership.Role)
}
return nil
}
func (s *PlatformService) requireProjectRole(ctx context.Context, actor *models.Actor, projectID uuid.UUID, allowedRoles ...string) error {
if actor == nil {
return errors.New("authentication required")
}
if actor.IsAPIKey() {
if actor.ProjectID != nil && *actor.ProjectID == projectID {
return nil
}
return errors.New("project access denied")
}
if actor.UserID == nil {
return errors.New("user actor required")
}
membership, err := s.repo.Queries().GetProjectMembership(ctx, db.GetProjectMembershipParams{
ProjectID: projectID,
UserID: *actor.UserID,
})
if err != nil {
return err
}
if !containsRole(membership.Role, allowedRoles...) {
return fmt.Errorf("project role %s is insufficient", membership.Role)
}
return nil
}
func (s *PlatformService) requireBucketRead(ctx context.Context, actor *models.Actor, bucket db.GetBucketByIDRow) error {
if bucket.Visibility == "public" {
return nil
}
return s.requireProjectRole(ctx, actor, bucket.ProjectID, "admin", "developer", "viewer")
}
func (s *PlatformService) requireBucketWrite(ctx context.Context, actor *models.Actor, bucket db.GetBucketByIDRow) error {
return s.requireProjectRole(ctx, actor, bucket.ProjectID, "admin", "developer")
}
func containsRole(current string, allowed ...string) bool {
for _, role := range allowed {
if role == current {
return true
}
}
return false
}
func normalizeSlug(input string) string {
slug := strings.ToLower(strings.TrimSpace(input))
slug = strings.ReplaceAll(slug, " ", "-")
return slug
}
func randomString(length int) (string, error) {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
buffer := make([]byte, length)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
for i := range buffer {
buffer[i] = alphabet[int(buffer[i])%len(alphabet)]
}
return string(buffer), nil
}
func randomToken(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
func digestString(input string) string {
sum := sha256.Sum256([]byte(input))
return hex.EncodeToString(sum[:])
}
func newAuditParams(orgID, projectID uuid.UUID, actor *models.Actor, requestID, action, resourceType, resourceID string, metadata map[string]any) db.CreateAuditLogParams {
payload, _ := json.Marshal(metadata)
params := db.CreateAuditLogParams{
Action: action,
ResourceType: resourceType,
ResourceID: resourceID,
Metadata: payload,
RequestID: requestID,
}
if orgID != uuid.Nil {
params.OrganizationID = pgtype.UUID{Bytes: orgID, Valid: true}
}
if projectID != uuid.Nil {
params.ProjectID = pgtype.UUID{Bytes: projectID, Valid: true}
}
if actor != nil {
if actor.UserID != nil {
params.ActorUserID = pgtype.UUID{Bytes: *actor.UserID, Valid: true}
}
if actor.APIKeyID != nil {
params.ActorApiKeyID = pgtype.UUID{Bytes: *actor.APIKeyID, Valid: true}
}
}
return params
}
func pgUUIDOrNil(value pgtype.UUID) uuid.UUID {
if value.Valid {
return value.Bytes
}
return uuid.Nil
}