diff --git a/src/Share/Web/Errors.hs b/src/Share/Web/Errors.hs index 03a39095..238cf7fb 100644 --- a/src/Share/Web/Errors.hs +++ b/src/Share/Web/Errors.hs @@ -18,6 +18,7 @@ module Share.Web.Errors MissingExpectedEntity (..), Unimplemented (..), BadRequest (..), + Forbidden (..), InvalidParam (..), NotAuthorized (..), ErrorID (..), @@ -314,6 +315,15 @@ instance ToServerError BadRequest where instance Loggable BadRequest where toLog (BadRequest msg) = withSeverity UserFault . textLog $ msg +data Forbidden = Forbidden Text + deriving (Eq, Show) + +instance ToServerError Forbidden where + toServerError (Forbidden msg) = (ErrorID "forbidden", err403 {errBody = BL.fromStrict . Text.encodeUtf8 $ msg}) + +instance Loggable Forbidden where + toLog (Forbidden msg) = withSeverity UserFault . textLog $ msg + data InvalidParam = InvalidParam {paramName :: Text, param :: Text, parseError :: Text} instance ToServerError InvalidParam where diff --git a/src/Share/Web/Share/Projects/Impl.hs b/src/Share/Web/Share/Projects/Impl.hs index 1df6fe37..1278af0e 100644 --- a/src/Share/Web/Share/Projects/Impl.hs +++ b/src/Share/Web/Share/Projects/Impl.hs @@ -31,7 +31,7 @@ import Share.Postgres.Projects.Queries qualified as ProjectsQ import Share.Postgres.Queries qualified as Q import Share.Postgres.Releases.Queries qualified as RQ import Share.Prelude -import Share.Project (Project (..)) +import Share.Project (Project (..), ProjectVisibility (ProjectPrivate)) import Share.Release qualified as Release import Share.User (User (..)) import Share.Utils.API ((:++) (..)) @@ -48,6 +48,8 @@ import Share.Web.Share.Branches.Impl (branchesServer, getProjectBranchReadmeEndp import Share.Web.Share.Contributions.Impl (contributionsByProjectServer) import Share.Web.Share.Diffs.Impl qualified as Diffs import Share.Web.Share.Diffs.Types (ShareNamespaceDiffResponse (..), ShareNamespaceDiffStatus (..), ShareTermDiffResponse (..), ShareTypeDiffResponse (..)) +import Share.Web.Share.Orgs.Queries qualified as OrgQ +import Share.Web.Share.Orgs.Types (Org (..)) import Share.Web.Share.Projects.API qualified as API import Share.Web.Share.Projects.Types import Share.Web.Share.Releases.Impl (getProjectReleaseReadmeEndpoint, releasesServer) @@ -320,6 +322,13 @@ updateProjectEndpoint (AuthN.MaybeAuthedUserID callerUserId) userHandle projectS addRequestTag "project-id" (IDs.toText projectId) AuthZ.permissionGuard $ AuthZ.checkProjectUpdate callerUserId projectId let UpdateProjectRequest {summary, tags, visibility} = req + when (visibility == Just ProjectPrivate) $ do + mayOrg <- PG.runTransaction (OrgQ.orgByUserHandle userHandle) + case mayOrg of + Just Org {isCommercial = False} -> do + respondError $ Forbidden "Please upgrade to a commercial org to enable private projects." + _ -> pure () + success <- PG.runTransaction $ Q.updateProject projectId summary tags visibility when (not success) $ respondError (EntityMissing (ErrorID "missing-project") "Project could not be found") pure () diff --git a/src/Share/Web/UCM/Projects/Impl.hs b/src/Share/Web/UCM/Projects/Impl.hs index 1c61dba6..935d139e 100644 --- a/src/Share/Web/UCM/Projects/Impl.hs +++ b/src/Share/Web/UCM/Projects/Impl.hs @@ -38,6 +38,8 @@ import Share.Web.Authorization qualified as AuthZ import Share.Web.Errors (InternalServerError (..), respondError) import Share.Web.Errors qualified as Errors import Share.Web.Share.Contributions.MergeDetection qualified as MergeDetection +import Share.Web.Share.Orgs.Queries qualified as OrgQ +import Share.Web.Share.Orgs.Types (Org (..)) import Share.Web.UCM.Sync.HashJWT qualified as HashJWT import Share.Web.UCM.Sync.Impl qualified as SyncQ import Unison.Server.Orphans () @@ -87,10 +89,14 @@ getProjectEndpoint (AuthN.MaybeAuthedUserID callerUserId) mayUcmProjectId mayUcm createProjectEndpoint :: Maybe Session -> UCMProjects.CreateProjectRequest -> WebApp UCMProjects.CreateProjectResponse createProjectEndpoint (AuthN.MaybeAuthedUserID callerUserId) (UCMProjects.CreateProjectRequest {projectName}) = toResponse do ProjectShortHand {userHandle, projectSlug} <- lift $ parseParam @ProjectShortHand "projectName" projectName - User {user_id = targetUserId} <- pgT do - UserQ.userByHandle userHandle `orThrow` UCMProjects.CreateProjectResponseNotFound (UCMProjects.NotFound "User not found") + (User {user_id = targetUserId}, mayOrg) <- pgT do + user@(User {user_id}) <- UserQ.userByHandle userHandle `orThrow` UCMProjects.CreateProjectResponseNotFound (UCMProjects.NotFound "User not found") + mayOrg <- OrgQ.orgByUserId user_id + pure (user, mayOrg) AuthZ.checkProjectCreate callerUserId targetUserId `ifUnauthorized` UCMProjects.CreateProjectResponseUnauthorized - let visibility = ProjectPrivate + let visibility = case mayOrg of + Nothing -> ProjectPrivate + Just (Org {isCommercial}) -> if isCommercial then ProjectPrivate else ProjectPublic let summary = Nothing let tags = mempty projectId <- lift $ PGO.createProject targetUserId projectSlug summary tags visibility diff --git a/transcripts/share-apis/orgs/commercial-org-projects.json b/transcripts/share-apis/orgs/commercial-org-projects.json new file mode 100644 index 00000000..bafac4b6 --- /dev/null +++ b/transcripts/share-apis/orgs/commercial-org-projects.json @@ -0,0 +1,24 @@ +{ + "body": [ + { + "createdAt": "", + "isFaved": false, + "numFavs": 0, + "owner": { + "handle": "@acme", + "name": "ACME", + "type": "organization" + }, + "slug": "proj", + "summary": null, + "tags": [], + "updatedAt": "", + "visibility": "private" + } + ], + "status": [ + { + "status_code": 200 + } + ] +} diff --git a/transcripts/share-apis/orgs/create-org-projects.md b/transcripts/share-apis/orgs/create-org-projects.md new file mode 100644 index 00000000..f820cec5 --- /dev/null +++ b/transcripts/share-apis/orgs/create-org-projects.md @@ -0,0 +1,6 @@ +Create new projects in both the `@acme` and `@noncom` organizations. + +```ucm +scratch/main> push @acme/proj +scratch/main> push @noncom/proj +``` diff --git a/transcripts/share-apis/orgs/create-org-projects.output.md b/transcripts/share-apis/orgs/create-org-projects.output.md new file mode 100644 index 00000000..5baa1d77 --- /dev/null +++ b/transcripts/share-apis/orgs/create-org-projects.output.md @@ -0,0 +1,19 @@ +Create new projects in both the `@acme` and `@noncom` organizations. + +``` ucm +scratch/main> push @acme/proj + + Uploaded 1 entities. + + I just created @acme/proj on http://localhost:5424 + + View it here: @acme/proj/main on http://localhost:5424 + +scratch/main> push @noncom/proj + + Uploaded 1 entities. + + I just created @noncom/proj on http://localhost:5424 + + View it here: @noncom/proj/main on http://localhost:5424 +``` diff --git a/transcripts/share-apis/orgs/non-com-project-privatization.json b/transcripts/share-apis/orgs/non-com-project-privatization.json new file mode 100644 index 00000000..bf9ad3ea --- /dev/null +++ b/transcripts/share-apis/orgs/non-com-project-privatization.json @@ -0,0 +1,8 @@ +{ + "body": "Please upgrade to a commercial org to enable private projects.", + "status": [ + { + "status_code": 403 + } + ] +} diff --git a/transcripts/share-apis/orgs/non-commercial-org-projects.json b/transcripts/share-apis/orgs/non-commercial-org-projects.json new file mode 100644 index 00000000..e34dd953 --- /dev/null +++ b/transcripts/share-apis/orgs/non-commercial-org-projects.json @@ -0,0 +1,24 @@ +{ + "body": [ + { + "createdAt": "", + "isFaved": false, + "numFavs": 0, + "owner": { + "handle": "@noncom", + "name": "Noncom", + "type": "organization" + }, + "slug": "proj", + "summary": null, + "tags": [], + "updatedAt": "", + "visibility": "public" + } + ], + "status": [ + { + "status_code": 200 + } + ] +} diff --git a/transcripts/share-apis/orgs/org-create-by-admin.json b/transcripts/share-apis/orgs/org-create-by-admin-commercial.json similarity index 100% rename from transcripts/share-apis/orgs/org-create-by-admin.json rename to transcripts/share-apis/orgs/org-create-by-admin-commercial.json diff --git a/transcripts/share-apis/orgs/org-create-by-admin-non-commercial.json b/transcripts/share-apis/orgs/org-create-by-admin-non-commercial.json new file mode 100644 index 00000000..1cf78384 --- /dev/null +++ b/transcripts/share-apis/orgs/org-create-by-admin-non-commercial.json @@ -0,0 +1,17 @@ +{ + "body": { + "isCommercial": false, + "orgId": "ORG-", + "user": { + "avatarUrl": "https://example.com/peace.png", + "handle": "noncom", + "name": "Noncom", + "userId": "U-" + } + }, + "status": [ + { + "status_code": 200 + } + ] +} diff --git a/transcripts/share-apis/orgs/run.zsh b/transcripts/share-apis/orgs/run.zsh index d6d55606..7eb83a08 100755 --- a/transcripts/share-apis/orgs/run.zsh +++ b/transcripts/share-apis/orgs/run.zsh @@ -16,11 +16,19 @@ fetch "$unauthorized_user" POST org-create-unauthorized '/orgs' '{ "avatarUrl": "https://example.com/anvil.png", "owner": "unauthorized", "email": "wile.e.coyote@example.com", - "isCommercial": false + "isCommercial": true }' # Admin can create an org and assign any owner. -fetch "$admin_user" POST org-create-by-admin '/orgs' '{ +fetch "$admin_user" POST org-create-by-admin-non-commercial '/orgs' '{ + "name": "Noncom", + "handle": "noncom", + "avatarUrl": "https://example.com/peace.png", + "owner": "transcripts", + "isCommercial": false +}' + +fetch "$admin_user" POST org-create-by-admin-commercial '/orgs' '{ "name": "ACME", "handle": "acme", "avatarUrl": "https://example.com/anvil.png", @@ -74,3 +82,18 @@ fetch "$transcripts_user" DELETE org-remove-members '/orgs/acme/members' '{ }' fetch "$transcripts_user" GET org-get-members-after-removing '/orgs/acme/members' + +# Create projects in each org. +transcript_ucm transcript create-org-projects.md + +# Get projects for each org +# Commercial projects should be private by default +fetch "$transcripts_user" GET commercial-org-projects '/users/acme/projects' + +# Non-commercial projects must be public by default +fetch "$transcripts_user" GET non-commercial-org-projects '/users/noncom/projects' + +# Updating a non-commercial org's project to private should fail +fetch "$transcripts_user" PATCH non-com-project-privatization '/users/noncom/projects/proj' '{ + "visibility": "private" +}'