diff --git a/Makefile b/Makefile index ddf538cd..73f5acfc 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all clean install docker_server_build docker_push serve auth_example transcripts fixtures transcripts reset_fixtures +.PHONY: all clean install docker_server_build docker_push serve auth_example transcripts fixtures transcripts serve_transcripts reset_fixtures SHARE_PROJECT_ROOT := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) export SHARE_PROJECT_ROOT @@ -81,6 +81,14 @@ reset_fixtures: $(installed_share) ) @echo "Done!"; + +serve_transcripts: $(installed_share) + @echo "Taking down any existing docker dependencies" + @docker compose -f docker/docker-compose.base.yml down || true + @trap 'docker compose -f docker/docker-compose.base.yml down' EXIT INT TERM + @echo "Booting up transcript docker dependencies..." + docker compose -f docker/docker-compose.base.yml up --remove-orphans + transcripts: $(installed_share) @echo "Taking down any existing docker dependencies" @docker compose -f docker/docker-compose.base.yml down || true diff --git a/share-client/src/Share/Client/Orgs.hs b/share-client/src/Share/Client/Orgs.hs index 5020d369..2ff4b922 100644 --- a/share-client/src/Share/Client/Orgs.hs +++ b/share-client/src/Share/Client/Orgs.hs @@ -4,9 +4,6 @@ module Share.Client.Orgs ( createOrg, - addOrgRoles, - listOrgRoles, - removeOrgRoles, listOrgMembers, addOrgMembers, removeOrgMembers, @@ -36,20 +33,9 @@ import Share.Web.Share.Orgs.Types orgsClient :: Client ClientM OrgsAPI orgsClient = client (Proxy :: Proxy OrgsAPI) -resourceRoutes :: UserHandle -> Client ClientM (NamedRoutes OrgsAPI.ResourceRoutes) -orgRolesRoutes :: UserHandle -> Client ClientM (NamedRoutes OrgsAPI.OrgRolesRoutes) -orgRolesRoutes = OrgsAPI.orgRoles <$> resourceRoutes - orgMembersRoutes :: UserHandle -> Client ClientM (NamedRoutes OrgsAPI.OrgMembersRoutes) orgMembersRoutes = OrgsAPI.orgMembers <$> resourceRoutes -listOrgRoles :: JWT.SignedJWT -> UserHandle -> ClientM ListRolesResponse -listOrgRoles jwt userHandle = OrgsAPI.listOrgRoles (orgRolesRoutes userHandle) (jwtToAuthenticatedRequest jwt) - -removeOrgRoles :: JWT.SignedJWT -> UserHandle -> OrgsAPI.RemoveRolesRequest -> ClientM ListRolesResponse -removeOrgRoles jwt userHandle removeRolesReq = - OrgsAPI.removeOrgRoles (orgRolesRoutes userHandle) (jwtToAuthenticatedRequest jwt) removeRolesReq - listOrgMembers :: JWT.SignedJWT -> UserHandle -> ClientM OrgMembersListResponse listOrgMembers jwt userHandle = OrgsAPI.listOrgMembers (orgMembersRoutes userHandle) (jwtToAuthenticatedRequest jwt) @@ -61,13 +47,10 @@ removeOrgMembers :: JWT.SignedJWT -> UserHandle -> OrgMembersRemoveRequest -> Cl removeOrgMembers jwt userHandle removeMembersReq = OrgsAPI.removeOrgMembers (orgMembersRoutes userHandle) (jwtToAuthenticatedRequest jwt) removeMembersReq -addOrgRoles :: JWT.SignedJWT -> UserHandle -> OrgsAPI.AddRolesRequest -> ClientM ListRolesResponse -addOrgRoles jwt userHandle addRolesReq = - OrgsAPI.addOrgRoles (orgRolesRoutes userHandle) (jwtToAuthenticatedRequest jwt) addRolesReq - createOrg :: JWT.SignedJWT -> CreateOrgRequest -> ClientM OrgDisplayInfo createOrg jwt createOrgReq = createOrg' (jwtToAuthenticatedRequest jwt) createOrgReq createOrg' :: AuthenticatedRequest AuthenticatedUserId -> CreateOrgRequest -> ClientM OrgDisplayInfo +resourceRoutes :: UserHandle -> OrgsAPI.ResourceRoutes (AsClientT ClientM) (createOrg' :<|> resourceRoutes) = orgsClient diff --git a/sql/2025-04-08_public_resource_permissions.sql b/sql/2025-04-08_public_resource_permissions.sql index 39b46ba2..c3273853 100644 --- a/sql/2025-04-08_public_resource_permissions.sql +++ b/sql/2025-04-08_public_resource_permissions.sql @@ -34,7 +34,8 @@ CREATE OR REPLACE VIEW user_resource_permissions(user_id, resource_id, permissio JOIN subject_resource_permissions srp ON sbu.subject_id = srp.subject_id UNION - -- Include public resource permissions + -- Include public resource permissions explicitly, + -- since the above joins on subject_id which is NULL for public perms SELECT NULL, prp.resource_id, permission FROM public_resource_permissions prp ); diff --git a/sql/2025-09-22_org_membership_roles.sql b/sql/2025-09-22_org_membership_roles.sql new file mode 100644 index 00000000..023525d3 --- /dev/null +++ b/sql/2025-09-22_org_membership_roles.sql @@ -0,0 +1,105 @@ +-- Org membership is now associated with a specific role within the org, this simplifies things, +-- makes the data more consistent, no need to rely on triggers, and makes it much easier to display in the UI. + +-- Update the role permissions, some roles had an incorrect 'org:create_project' rather than 'project:create' permission +-- UPDATE roles +-- SET permissions = ARRAY['org:view', 'org:manage', 'org:admin', 'team:view', 'team:manage', 'project:view', 'project:create', 'project:manage', 'project:contribute', 'project:delete', 'project:maintain'] +-- WHERE ref = 'org_admin' +-- ; + +-- UPDATE roles +-- SET permissions = ARRAY['org:view', 'org:manage', 'org:admin', 'org:delete', 'org:change_owner', 'team:view', 'team:manage', 'project:view', 'project:create', 'project:manage', 'project:contribute', 'project:delete', 'project:maintain'] +-- WHERE ref = 'org_owner' +-- ; + +-- -- After migrating, this will no longer be used. +-- UPDATE roles +-- SET permissions = ARRAY['org:view', 'org:edit', 'team:view', 'project:view', 'project:create', 'project:maintain', 'project:contribute'] +-- WHERE ref = 'org_default' +-- ; + +ALTER TABLE org_members + ADD COLUMN role_id UUID REFERENCES roles(id) NULL; + +-- set all existing org members to be maintiners +UPDATE org_members + SET role_id = (SELECT id FROM roles WHERE ref = 'org_maintainer' LIMIT 1); + +-- Elevate the current org owners to have the org_owner role +UPDATE org_members + SET role_id = (SELECT id FROM roles WHERE ref = 'org_owner' LIMIT 1) + WHERE EXISTS ( + SELECT + FROM orgs org + JOIN users u ON org_members.member_user_id = u.id + JOIN role_memberships rm ON rm.subject_id = u.subject_id AND rm.resource_id = org.resource_id + JOIN roles r ON rm.role_id = r.id + WHERE org.id = org_members.org_id + AND r.ref = 'org_owner' + ); + +ALTER TABLE org_members + ALTER COLUMN role_id SET NOT NULL; + +-- Now add a check that each org always has an owner. +CREATE OR REPLACE FUNCTION check_orgs_have_an_owner() +RETURNS trigger AS $$ +BEGIN + IF NOT EXISTS ( + SELECT + FROM org_members om + WHERE om.org_id = OLD.org_id + AND om.role_id = (SELECT id FROM roles WHERE ref = 'org_owner' LIMIT 1) + ) THEN + RAISE EXCEPTION 'Each organization must have at least one owner.'; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER check_org_owners_trigger + AFTER UPDATE OR DELETE ON org_members + FOR EACH ROW + EXECUTE FUNCTION check_orgs_have_an_owner(); + +-- Split out view containing all the direct subject<->resource permissions. +-- +-- This view expands the roles into their individual permissions +-- but does not consider resource hierarchy or group memberships +CREATE OR REPLACE VIEW direct_resource_permissions(subject_id, resource_id, permission) AS ( + SELECT rm.subject_id, rm.resource_id, permission + FROM role_memberships rm + JOIN roles r ON rm.role_id = r.id + , UNNEST(r.permissions) AS permission + UNION + -- Include permissions from org membership roles + SELECT u.subject_id, org.resource_id, permission + FROM org_members om + JOIN users u ON om.member_user_id = u.id + JOIN roles r ON om.role_id = r.id + JOIN orgs org ON om.org_id = org.id + , UNNEST(r.permissions) AS permission + UNION + -- Include public resource permissions + SELECT NULL, prp.resource_id, permission + FROM public_resource_permissions prp +); + + +-- This view builds on top of direct_resource_permissions to include inherited permissions +CREATE OR REPLACE VIEW subject_resource_permissions(subject_id, resource_id, permission) AS ( + SELECT drp.subject_id, drp.resource_id, drp.permission + FROM direct_resource_permissions drp + UNION + -- Inherit permissions from parent resources + SELECT drp.subject_id, rh.resource_id, drp.permission + FROM direct_resource_permissions drp + JOIN resource_hierarchy rh ON drp.resource_id = rh.parent_resource_id +); + + +DELETE FROM role_memberships rm + USING roles r + WHERE + rm.role_id = r.id + AND r.ref::text IN ('org_viewer', 'org_maintainer', 'org_contributor', 'org_admin', 'org_owner', 'org_default'); diff --git a/src/Share/Postgres/Projects/Queries.hs b/src/Share/Postgres/Projects/Queries.hs index 09ef7e71..58882757 100644 --- a/src/Share/Postgres/Projects/Queries.hs +++ b/src/Share/Postgres/Projects/Queries.hs @@ -27,7 +27,7 @@ isPremiumProject projId = SELECT EXISTS (SELECT FROM premium_projects WHERE project_id = #{projId}) |] -listProjectRoles :: ProjectId -> Transaction e [(RoleAssignment ResolvedAuthSubject)] +listProjectRoles :: ProjectId -> Transaction e [(RoleAssignment Set ResolvedAuthSubject)] listProjectRoles projId = do queryListRows @(ResolvedAuthSubject PG.:. Only ([RoleRef])) [sql| @@ -42,7 +42,7 @@ listProjectRoles projId = do |] <&> fmap \(subject PG.:. Only roleRefs) -> (RoleAssignment {subject, roles = Set.fromList roleRefs}) -addProjectRoles :: ProjectId -> [RoleAssignment ResolvedAuthSubject] -> Transaction e [(RoleAssignment ResolvedAuthSubject)] +addProjectRoles :: ProjectId -> [RoleAssignment Set ResolvedAuthSubject] -> Transaction e [(RoleAssignment Set ResolvedAuthSubject)] addProjectRoles projId toAdd = do let addedRolesTable = toAdd @@ -66,7 +66,7 @@ addProjectRoles projId toAdd = do |] listProjectRoles projId -removeProjectRoles :: ProjectId -> [RoleAssignment ResolvedAuthSubject] -> Transaction e [(RoleAssignment ResolvedAuthSubject)] +removeProjectRoles :: ProjectId -> [RoleAssignment Set ResolvedAuthSubject] -> Transaction e [(RoleAssignment Set ResolvedAuthSubject)] removeProjectRoles projId toRemove = do let removedRolesTable = toRemove diff --git a/src/Share/Prelude.hs b/src/Share/Prelude.hs index a843e408..880f2a7c 100644 --- a/src/Share/Prelude.hs +++ b/src/Share/Prelude.hs @@ -18,6 +18,7 @@ module Share.Prelude altSum, altMap, foldMapM, + foldMapMOf, onNothing, onNothingM, whenNothing, @@ -56,6 +57,7 @@ where import Control.Applicative as X import Control.Arrow ((&&&)) import Control.Category hiding (id, (.)) +import Control.Lens import Control.Monad as X import Control.Monad.Except (throwError) import Control.Monad.Reader as X @@ -73,7 +75,6 @@ import Data.Foldable as X import Data.Function as X import Data.Functor as X import Data.Functor.Compose (Compose (..)) -import Data.Functor.Contravariant (contramap) import Data.Functor.Identity as X import Data.Int as X import Data.List.NonEmpty (NonEmpty (..)) @@ -219,3 +220,8 @@ unifyEither = either id id traverseFirst :: (Bitraversable t, Applicative f) => (a -> f b) -> t a x -> f (t b x) traverseFirst f = bitraverse f pure + +foldMapMOf :: (Monad m, Monoid w) => Fold s a -> (a -> m w) -> s -> m w +foldMapMOf foc f s = + toListOf foc s + & foldMapM f diff --git a/src/Share/Web/Authorization/Types.hs b/src/Share/Web/Authorization/Types.hs index 0b5fe3fd..72a3d877 100644 --- a/src/Share/Web/Authorization/Types.hs +++ b/src/Share/Web/Authorization/Types.hs @@ -1,6 +1,9 @@ {-# LANGUAGE DataKinds #-} +{-# LANGUAGE QuantifiedConstraints #-} {-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE UndecidableInstances #-} module Share.Web.Authorization.Types ( RolePermission (..), @@ -338,25 +341,45 @@ instance Hasql.EncodeValue RoleRef where RoleProjectOwner -> "project_owner" RoleProjectPublicAccess -> "project_public_access" -data RoleAssignment subject = RoleAssignment +data RoleAssignment f subject = RoleAssignment { subject :: subject, - roles :: Set RoleRef + roles :: f RoleRef } - deriving (Show, Functor, Foldable, Traversable) + deriving (Functor, Foldable, Traversable) -instance (ToJSON user) => ToJSON (RoleAssignment user) where +deriving instance (Eq (f RoleRef), Eq subject) => Eq (RoleAssignment f subject) + +deriving instance (Ord (f RoleRef), Ord subject) => Ord (RoleAssignment f subject) + +deriving instance (Show (f RoleRef), Show subject) => Show (RoleAssignment f subject) + +instance {-# OVERLAPPING #-} (ToJSON user) => ToJSON (RoleAssignment Identity user) where + toJSON RoleAssignment {..} = + object + [ "subject" Aeson..= subject, + "role" Aeson..= roles + ] + +instance {-# OVERLAPPABLE #-} (ToJSON user, ToJSON (f RoleRef)) => ToJSON (RoleAssignment f user) where toJSON RoleAssignment {..} = object [ "subject" Aeson..= subject, "roles" Aeson..= roles ] -instance (FromJSON user) => FromJSON (RoleAssignment user) where +instance {-# OVERLAPPING #-} (FromJSON user) => FromJSON (RoleAssignment Identity user) where + parseJSON = Aeson.withObject "RoleAssignment" $ \o -> do + subject <- o Aeson..: "subject" + roles <- o Aeson..: "role" + pure RoleAssignment {..} + +instance {-# OVERLAPPABLE #-} (FromJSON user, FromJSON (f RoleRef)) => FromJSON (RoleAssignment f user) where parseJSON = Aeson.withObject "RoleAssignment" $ \o -> do subject <- o Aeson..: "subject" roles <- o Aeson..: "roles" pure RoleAssignment {..} + -- | A type for mixing in permissions info on a response for a resource. newtype PermissionsInfo = PermissionsInfo (Set RolePermission) deriving (Show) @@ -385,84 +408,87 @@ instance FromJSON ProjectMaintainerPermissions where pure ProjectMaintainerPermissions {canView, canMaintain, canAdmin} -- Generic request/response types -data ListRolesResponse = ListRolesResponse +data ListRolesResponse f = ListRolesResponse { -- Whether the caller is able to edit the roles. active :: Bool, - roleAssignments :: [RoleAssignment DisplayAuthSubject] + roleAssignments :: [RoleAssignment f DisplayAuthSubject] } - deriving (Show) -instance ToJSON ListRolesResponse where +deriving instance (Show (f RoleRef)) => Show (ListRolesResponse f) + +instance (ToJSON (f RoleRef)) => ToJSON (ListRolesResponse f) where toJSON ListRolesResponse {..} = object [ "role_assignments" Aeson..= roleAssignments, "active" .= active ] -instance FromJSON ListRolesResponse where +instance (FromJSON (f RoleRef)) => FromJSON (ListRolesResponse f) where parseJSON = Aeson.withObject "ListRolesResponse" $ \o -> do roleAssignments <- o Aeson..: "role_assignments" active <- o Aeson..: "active" pure ListRolesResponse {roleAssignments, active} -data AddRolesResponse = AddRolesResponse - { roleAssignments :: [RoleAssignment DisplayAuthSubject] +data AddRolesResponse f = AddRolesResponse + { roleAssignments :: [RoleAssignment f DisplayAuthSubject] } -instance ToJSON AddRolesResponse where +instance (ToJSON (f RoleRef)) => ToJSON (AddRolesResponse f) where toJSON AddRolesResponse {..} = object [ "role_assignments" Aeson..= roleAssignments ] -instance FromJSON AddRolesResponse where +instance (FromJSON (f RoleRef)) => FromJSON (AddRolesResponse f) where parseJSON = Aeson.withObject "AddRolesResponse" $ \o -> do roleAssignments <- o Aeson..: "role_assignments" pure AddRolesResponse {roleAssignments} -data RemoveRolesResponse = RemoveRolesResponse - { roleAssignments :: [RoleAssignment DisplayAuthSubject] +data RemoveRolesResponse f = RemoveRolesResponse + { roleAssignments :: [RoleAssignment f DisplayAuthSubject] } -instance ToJSON RemoveRolesResponse where +instance (ToJSON (f RoleRef)) => ToJSON (RemoveRolesResponse f) where toJSON RemoveRolesResponse {..} = object [ "role_assignments" Aeson..= roleAssignments ] -instance FromJSON RemoveRolesResponse where +instance (FromJSON (f RoleRef)) => FromJSON (RemoveRolesResponse f) where parseJSON = Aeson.withObject "RemoveRolesResponse" $ \o -> do roleAssignments <- o Aeson..: "role_assignments" pure RemoveRolesResponse {roleAssignments} -data AddRolesRequest = AddRolesRequest - { roleAssignments :: [RoleAssignment ResolvedAuthSubject] +data AddRolesRequest f = AddRolesRequest + { roleAssignments :: [RoleAssignment f ResolvedAuthSubject] } - deriving (Show) -instance ToJSON AddRolesRequest where +deriving instance (Show (f RoleRef)) => Show (AddRolesRequest f) + +instance (ToJSON (f RoleRef)) => ToJSON (AddRolesRequest f) where toJSON AddRolesRequest {..} = object [ "role_assignments" .= roleAssignments ] -instance FromJSON AddRolesRequest where +instance (FromJSON (f RoleRef)) => FromJSON (AddRolesRequest f) where parseJSON = Aeson.withObject "AddRolesRequest" $ \o -> do roleAssignments <- o Aeson..: "role_assignments" pure AddRolesRequest {..} -data RemoveRolesRequest = RemoveRolesRequest - { roleAssignments :: [RoleAssignment ResolvedAuthSubject] +data RemoveRolesRequest f = RemoveRolesRequest + { roleAssignments :: [RoleAssignment f ResolvedAuthSubject] } - deriving (Show) -instance ToJSON RemoveRolesRequest where +deriving instance (Show (f RoleRef)) => Show (RemoveRolesRequest f) + +instance (ToJSON (f RoleRef)) => ToJSON (RemoveRolesRequest f) where toJSON RemoveRolesRequest {..} = object [ "role_assignments" .= roleAssignments ] -instance FromJSON RemoveRolesRequest where +instance (FromJSON (f RoleRef)) => FromJSON (RemoveRolesRequest f) where parseJSON = Aeson.withObject "RemoveRolesRequest" $ \o -> do roleAssignments <- o Aeson..: "role_assignments" pure RemoveRolesRequest {..} diff --git a/src/Share/Web/Share/Orgs/API.hs b/src/Share/Web/Share/Orgs/API.hs index 6d718cea..d90b26fb 100644 --- a/src/Share/Web/Share/Orgs/API.hs +++ b/src/Share/Web/Share/Orgs/API.hs @@ -4,7 +4,6 @@ module Share.Web.Share.Orgs.API ( API, ResourceRoutes (..), - OrgRolesRoutes (..), OrgMembersRoutes (..), ) where @@ -13,7 +12,6 @@ import GHC.Generics (Generic) import Servant import Share.IDs import Share.OAuth.Session (AuthenticatedUserId) -import Share.Web.Authorization.Types (AddRolesRequest, ListRolesResponse, RemoveRolesRequest) import Share.Web.Share.DisplayInfo.Types (OrgDisplayInfo) import Share.Web.Share.Orgs.Types @@ -24,16 +22,7 @@ type API = data ResourceRoutes mode = ResourceRoutes - { orgRoles :: mode :- "roles" :> NamedRoutes OrgRolesRoutes, - orgMembers :: mode :- "members" :> NamedRoutes OrgMembersRoutes - } - deriving stock (Generic) - -data OrgRolesRoutes mode - = OrgRolesRoutes - { listOrgRoles :: mode :- OrgRolesListEndpoint, - addOrgRoles :: mode :- OrgRolesAddEndpoint, - removeOrgRoles :: mode :- OrgRolesRemoveEndpoint + { orgMembers :: mode :- "members" :> NamedRoutes OrgMembersRoutes } deriving stock (Generic) @@ -45,20 +34,6 @@ data OrgMembersRoutes mode } deriving stock (Generic) -type OrgRolesAddEndpoint = - AuthenticatedUserId - :> ReqBody '[JSON] AddRolesRequest - :> Post '[JSON] ListRolesResponse - -type OrgRolesRemoveEndpoint = - AuthenticatedUserId - :> ReqBody '[JSON] RemoveRolesRequest - :> Delete '[JSON] ListRolesResponse - -type OrgRolesListEndpoint = - AuthenticatedUserId - :> Get '[JSON] ListRolesResponse - type CreateOrgEndpoint = AuthenticatedUserId :> ReqBody '[JSON] CreateOrgRequest diff --git a/src/Share/Web/Share/Orgs/Impl.hs b/src/Share/Web/Share/Orgs/Impl.hs index dc46bc74..6b8cf0ad 100644 --- a/src/Share/Web/Share/Orgs/Impl.hs +++ b/src/Share/Web/Share/Orgs/Impl.hs @@ -3,8 +3,8 @@ module Share.Web.Share.Orgs.Impl (server) where import Control.Lens -import Data.Either (isRight) import Data.Map qualified as Map +import Data.Monoid (Any (..)) import Data.Set qualified as Set import Servant import Servant.Server.Generic @@ -16,16 +16,14 @@ import Share.User (User (..)) import Share.Utils.Logging qualified as Logging import Share.Web.App import Share.Web.Authorization qualified as AuthZ -import Share.Web.Authorization.Types +import Share.Web.Authorization.Types (RoleAssignment (..)) +import Share.Web.Authorization.Types qualified as AuthZ import Share.Web.Errors -import Share.Web.Share.DisplayInfo.Types (OrgDisplayInfo) +import Share.Web.Share.DisplayInfo.Types (OrgDisplayInfo, UserDisplayInfo (..)) import Share.Web.Share.Orgs.API as API import Share.Web.Share.Orgs.Operations qualified as OrgOps import Share.Web.Share.Orgs.Queries qualified as OrgQ import Share.Web.Share.Orgs.Types (CreateOrgRequest (..), Org (..), OrgMembersAddRequest (..), OrgMembersListResponse (..), OrgMembersRemoveRequest (..)) -import Share.Web.Share.Roles (canonicalRoleAssignmentOrdering) -import Share.Web.Share.Roles.Queries (displaySubjectsOf) -import Unison.Util.Set qualified as Set data OrgError = OrgMemberOfOrgError @@ -54,8 +52,7 @@ server :: ServerT API.API WebApp server = let orgResourceServer orgHandle = API.ResourceRoutes - { API.orgRoles = rolesServer orgHandle, - API.orgMembers = membersServer orgHandle + { API.orgMembers = membersServer orgHandle } in orgCreateEndpoint :<|> orgResourceServer @@ -66,14 +63,6 @@ orgCreateEndpoint callerUserId (CreateOrgRequest {name, handle, avatarUrl, email orgId <- PG.runTransactionOrRespondError $ OrgOps.createOrg authZReceipt name handle email avatarUrl ownerUserId callerUserId isCommercial PG.runTransaction $ OrgQ.orgDisplayInfoOf id orgId -rolesServer :: UserHandle -> API.OrgRolesRoutes (AsServerT WebApp) -rolesServer orgHandle = - API.OrgRolesRoutes - { API.listOrgRoles = listRolesEndpoint orgHandle, - API.addOrgRoles = addRolesEndpoint orgHandle, - API.removeOrgRoles = removeRolesEndpoint orgHandle - } - membersServer :: UserHandle -> API.OrgMembersRoutes (AsServerT WebApp) membersServer orgHandle = API.OrgMembersRoutes @@ -89,58 +78,6 @@ orgIdByHandle orgHandle = do respondError (EntityMissing (ErrorID "missing-org") "Organization not found") pure orgId -listRolesEndpoint :: UserHandle -> UserId -> WebApp ListRolesResponse -listRolesEndpoint orgHandle caller = do - orgId <- orgIdByHandle orgHandle - _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkReadOrgRolesList caller orgId - callerCanEdit <- isRight <$> AuthZ.checkEditOrgRoles caller orgId - PG.runTransaction do - orgRoles <- OrgQ.listOrgRoles orgId - ListRolesResponse callerCanEdit . canonicalRoleAssignmentOrdering <$> displaySubjectsOf (traversed . traversed) orgRoles - -addRolesEndpoint :: UserHandle -> UserId -> AddRolesRequest -> WebApp ListRolesResponse -addRolesEndpoint orgHandle caller (AddRolesRequest {roleAssignments}) = do - orgId <- orgIdByHandle orgHandle - _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkEditOrgRoles caller orgId - assertNoOrgSubjects roleAssignments - PG.runTransaction do - orgRoles <- OrgQ.addOrgRoles orgId roleAssignments - let newMembers = - (computeOrgMembershipChanges roleAssignments) - & Map.filter id - & Map.keysSet - OrgQ.addOrgMembers orgId newMembers - ListRolesResponse True . canonicalRoleAssignmentOrdering <$> displaySubjectsOf (traversed . traversed) orgRoles - -removeRolesEndpoint :: UserHandle -> UserId -> RemoveRolesRequest -> WebApp ListRolesResponse -removeRolesEndpoint orgHandle caller (RemoveRolesRequest {roleAssignments}) = do - orgId <- orgIdByHandle orgHandle - _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkEditOrgRoles caller orgId - PG.runTransactionOrRespondError do - let updatedUsersMap = - roleAssignments - & foldMap - ( \RoleAssignment {subject} -> - case subject of - UserSubject userId -> Map.singleton userId Set.empty - _ -> Map.empty - ) - orgRoles <- OrgQ.removeOrgRoles orgId roleAssignments - OrgQ.doesOrgHaveOwner orgId >>= \case - False -> throwError OrgMustHaveOwnerError - True -> pure () - let remainingRolesMap = computeOrgMembershipChanges orgRoles - let usersWithNoRemainingRoles = Map.keysSet updatedUsersMap `Set.difference` Map.keysSet remainingRolesMap - let evictedMembers = - remainingRolesMap - -- Only keep users who should no longer be members - & Map.filter not - & Map.keysSet - & Set.union usersWithNoRemainingRoles - OrgQ.removeOrgMembers orgId evictedMembers - - ListRolesResponse True . canonicalRoleAssignmentOrdering <$> displaySubjectsOf (traversed . traversed) orgRoles - listMembersEndpoint :: UserHandle -> UserId -> WebApp OrgMembersListResponse listMembersEndpoint orgHandle caller = do orgId <- orgIdByHandle orgHandle @@ -153,63 +90,32 @@ addMembersEndpoint orgHandle caller (OrgMembersAddRequest {members}) = do orgId <- orgIdByHandle orgHandle _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkEditOrgMembers caller orgId PG.runTransactionOrRespondError do - userIds <- UserQ.userIdsByHandlesOf Set.traverse (Set.fromList members) - hasOrgMember <- runMaybeT $ for_ userIds \userId -> do - MaybeT $ OrgQ.orgByUserId userId - when (isJust hasOrgMember) do + userIdAssignments :: [RoleAssignment Identity UserId] <- UserQ.userIdsByHandlesOf (traversed . traversed) members + Any newMemberIsOrg <- + userIdAssignments & foldMapMOf (folded . folded) \userId -> do + Any . isJust <$> OrgQ.orgByUserId userId + when newMemberIsOrg do throwError OrgMemberOfOrgError - OrgQ.addOrgMembers orgId userIds + let membersMap = + userIdAssignments + <&> (\RoleAssignment {subject, roles = Identity role} -> (subject, role)) + & Map.fromList + OrgQ.addOrgMembers orgId membersMap OrgMembersListResponse <$> OrgQ.listOrgMembers orgId removeMembersEndpoint :: UserHandle -> UserId -> OrgMembersRemoveRequest -> WebApp OrgMembersListResponse removeMembersEndpoint orgHandle caller (OrgMembersRemoveRequest {members}) = do orgId <- orgIdByHandle orgHandle _authZReceipt <- AuthZ.permissionGuard $ AuthZ.checkEditOrgMembers caller orgId - PG.runTransaction do - userIds <- UserQ.userIdsByHandlesOf Set.traverse (Set.fromList members) - OrgQ.removeOrgMembers orgId userIds - OrgMembersListResponse <$> OrgQ.listOrgMembers orgId - -assertNoOrgSubjects :: [RoleAssignment ResolvedAuthSubject] -> WebApp () -assertNoOrgSubjects roleAssignments = do - let hasOrgSubject = - roleAssignments - & any - ( \RoleAssignment {subject} -> case subject of - OrgSubject {} -> True - _ -> False - ) - when hasOrgSubject do - respondError OrgMemberOfOrgError - --- | This is part of a hack to temporarily fix org membership issues --- until we have time for a more robust solution. -shouldRoleBeOrgMember :: RoleRef -> Bool -shouldRoleBeOrgMember = \case - RoleOrgViewer -> False - RoleOrgContributor -> True - RoleOrgMaintainer -> True - RoleOrgAdmin -> True - RoleOrgOwner -> True - RoleOrgDefault -> True - RoleTeamAdmin -> True - RoleProjectViewer -> False - RoleProjectContributor -> True - RoleProjectMaintainer -> True - RoleProjectAdmin -> True - RoleProjectOwner -> True - RoleProjectPublicAccess -> False - --- | Returns a list of users and whether they should end up as members of the org or not -computeOrgMembershipChanges :: [RoleAssignment ResolvedAuthSubject] -> Map UserId Bool -computeOrgMembershipChanges roleAssignments = - roleAssignments - & foldMap - ( \RoleAssignment {subject, roles} -> - case subject of - UserSubject userId -> - let shouldBeMember = any shouldRoleBeOrgMember roles - in [(userId, shouldBeMember)] - _ -> mempty - ) - & Map.fromListWith (||) + PG.runTransactionOrRespondError do + userIds <- Set.fromList <$> UserQ.userIdsByHandlesOf traversed members + otherOwnerStillExists orgId userIds >>= \case + False -> throwError OrgMustHaveOwnerError + True -> do + OrgQ.removeOrgMembers orgId userIds + OrgMembersListResponse <$> OrgQ.listOrgMembers orgId + where + otherOwnerStillExists orgId removedMembers = do + OrgQ.listOrgMembers orgId + <&> any \RoleAssignment {subject = UserDisplayInfo {userId}, roles = Identity role} -> + role == AuthZ.RoleOrgOwner && not (Set.member userId removedMembers) diff --git a/src/Share/Web/Share/Orgs/Operations.hs b/src/Share/Web/Share/Orgs/Operations.hs index 14686494..47d9fd4c 100644 --- a/src/Share/Web/Share/Orgs/Operations.hs +++ b/src/Share/Web/Share/Orgs/Operations.hs @@ -3,7 +3,7 @@ module Share.Web.Share.Orgs.Operations ) where -import Data.Set qualified as Set +import Data.Map qualified as Map import Share.IDs (Email, OrgHandle (..), OrgId, UserHandle (..), UserId) import Share.Postgres import Share.Postgres.Users.Queries (UserCreationError) @@ -12,20 +12,18 @@ import Share.Prelude import Share.Utils.URI import Share.Web.Authorization.Types qualified as AuthZ import Share.Web.Share.Orgs.Queries qualified as OrgQ -import Share.Web.Share.Roles.Queries qualified as RoleQ createOrg :: AuthZ.AuthZReceipt -> Text -> OrgHandle -> Maybe Email -> Maybe URIParam -> UserId -> UserId -> Bool -> Transaction UserCreationError OrgId createOrg !authZReceipt name (OrgHandle handle) email avatarUrl owner creator isCommercial = do let emailVerified = False let isOrg = True orgUserId <- UserQ.createUser authZReceipt isOrg email (Just name) avatarUrl (UserHandle handle) emailVerified - (orgId, orgResourceId) <- - queryExpect1Row + orgId <- + queryExpect1Col [sql| INSERT INTO orgs (user_id, creator_user_id, is_commercial) VALUES (#{orgUserId}, #{creator}, #{isCommercial}) - RETURNING id, resource_id + RETURNING id |] - RoleQ.assignUserRoleMembership authZReceipt owner orgResourceId AuthZ.RoleOrgOwner - OrgQ.addOrgMembers orgId (Set.singleton owner) + OrgQ.addOrgMembers orgId (Map.singleton owner AuthZ.RoleOrgOwner) pure orgId diff --git a/src/Share/Web/Share/Orgs/Queries.hs b/src/Share/Web/Share/Orgs/Queries.hs index 8805b2fd..0a0bd720 100644 --- a/src/Share/Web/Share/Orgs/Queries.hs +++ b/src/Share/Web/Share/Orgs/Queries.hs @@ -17,6 +17,7 @@ module Share.Web.Share.Orgs.Queries where import Control.Lens +import Data.Map qualified as Map import Data.Set qualified as Set import Share.IDs (OrgId, UserHandle, UserId) import Share.Postgres @@ -93,7 +94,7 @@ userDisplayInfoByOrgIdOf trav s = do userId } -listOrgRoles :: OrgId -> Transaction e [RoleAssignment ResolvedAuthSubject] +listOrgRoles :: OrgId -> Transaction e [RoleAssignment Set ResolvedAuthSubject] listOrgRoles orgId = do queryListRows @(ResolvedAuthSubject :. Only [RoleRef]) [sql| @@ -107,7 +108,7 @@ listOrgRoles orgId = do |] <&> fmap \(subject :. Only roleRefs) -> RoleAssignment {subject, roles = Set.fromList roleRefs} -addOrgRoles :: OrgId -> [RoleAssignment ResolvedAuthSubject] -> Transaction e [RoleAssignment ResolvedAuthSubject] +addOrgRoles :: OrgId -> [RoleAssignment Set ResolvedAuthSubject] -> Transaction e [RoleAssignment Set ResolvedAuthSubject] addOrgRoles orgId newRoles = do let newRolesTable = newRoles @@ -130,7 +131,7 @@ addOrgRoles orgId newRoles = do |] listOrgRoles orgId -removeOrgRoles :: OrgId -> [RoleAssignment ResolvedAuthSubject] -> Transaction e [RoleAssignment ResolvedAuthSubject] +removeOrgRoles :: OrgId -> [RoleAssignment Set ResolvedAuthSubject] -> Transaction e [RoleAssignment Set ResolvedAuthSubject] removeOrgRoles orgId toRemove = do let removedRolesTable = toRemove @@ -154,34 +155,42 @@ removeOrgRoles orgId toRemove = do |] listOrgRoles orgId -listOrgMembers :: OrgId -> Transaction e [UserDisplayInfo] +listOrgMembers :: OrgId -> Transaction e [RoleAssignment Identity UserDisplayInfo] listOrgMembers orgId = do - queryListRows + queryListRows @((UserHandle, Maybe Text, Maybe URIParam, UserId) :. Only RoleRef) [sql| - SELECT u.handle, u.name, u.avatar_url, u.id + SELECT u.handle, u.name, u.avatar_url, u.id, role.ref :: role_ref FROM org_members om JOIN users u ON om.member_user_id = u.id + JOIN roles role ON role.id = om.role_id WHERE om.org_id = #{orgId} ORDER BY u.handle |] - <&> fmap \(handle, name, avatarUrl, userId) -> - UserDisplayInfo - { handle, - name, - avatarUrl = unpackURI <$> avatarUrl, - userId - } + <&> fmap \((handle, name, avatarUrl, userId) :. Only role) -> + let subject = + UserDisplayInfo + { handle, + name, + avatarUrl = unpackURI <$> avatarUrl, + userId + } + in RoleAssignment + { subject, + roles = Identity role + } -addOrgMembers :: OrgId -> Set UserId -> Transaction e () +addOrgMembers :: OrgId -> Map UserId RoleRef -> Transaction e () addOrgMembers orgId newMembers = do + let newMembersTable = Map.toList newMembers execute_ [sql| - WITH values(member_user_id) AS ( - SELECT t.member_user_id - FROM ^{singleColumnTable (toList newMembers)} AS t(member_user_id) - ) INSERT INTO org_members (org_id, organization_user_id, member_user_id) - SELECT #{orgId}, (SELECT o.user_id FROM orgs o WHERE o.id = #{orgId}), v.member_user_id + WITH values(member_user_id, role_ref) AS ( + SELECT t.member_user_id, t.role_ref + FROM ^{toTable newMembersTable} AS t(member_user_id, role_ref) + ) INSERT INTO org_members (org_id, organization_user_id, member_user_id, role_id) + SELECT #{orgId}, (SELECT o.user_id FROM orgs o WHERE o.id = #{orgId}), v.member_user_id, role.id FROM values v + JOIN roles role ON role.ref = (v.role_ref::role_ref) ON CONFLICT DO NOTHING |] diff --git a/src/Share/Web/Share/Orgs/Types.hs b/src/Share/Web/Share/Orgs/Types.hs index 6e2a1707..2bafdfc7 100644 --- a/src/Share/Web/Share/Orgs/Types.hs +++ b/src/Share/Web/Share/Orgs/Types.hs @@ -10,10 +10,11 @@ module Share.Web.Share.Orgs.Types where import Data.Aeson -import Data.Text (Text) import Share.IDs import Share.Postgres (DecodeRow (..), decodeField) +import Share.Prelude import Share.Utils.URI (URIParam) +import Share.Web.Authorization.Types (RoleAssignment (..)) import Share.Web.Share.DisplayInfo.Types (UserDisplayInfo) data Org = Org {orgId :: OrgId, isCommercial :: Bool} @@ -57,7 +58,7 @@ instance FromJSON CreateOrgRequest where pure CreateOrgRequest {..} data OrgMembersAddRequest = OrgMembersAddRequest - { members :: [UserHandle] + { members :: [RoleAssignment Identity UserHandle] } deriving (Show, Eq) @@ -73,7 +74,7 @@ instance FromJSON OrgMembersAddRequest where pure OrgMembersAddRequest {..} data OrgMembersListResponse = OrgMembersListResponse - { members :: [UserDisplayInfo] + { members :: [RoleAssignment Identity UserDisplayInfo] } deriving (Show, Eq) diff --git a/src/Share/Web/Share/Projects/API.hs b/src/Share/Web/Share/Projects/API.hs index 836266a6..23d6da73 100644 --- a/src/Share/Web/Share/Projects/API.hs +++ b/src/Share/Web/Share/Projects/API.hs @@ -3,6 +3,7 @@ module Share.Web.Share.Projects.API where +import Data.Set (Set) import Servant import Share.IDs import Share.OAuth.Session (MaybeAuthenticatedSession) @@ -98,21 +99,21 @@ type MaintainersResourceAPI = ) -- | List all maintainers of the project. -type ListRolesEndpoint = Get '[JSON] ListRolesResponse +type ListRolesEndpoint = Get '[JSON] (ListRolesResponse Set) -- | Add new maintainers to the project. type AddRolesEndpoint = - ReqBody '[JSON] AddRolesRequest + ReqBody '[JSON] (AddRolesRequest Set) :> -- Return the updated list of maintainers - Post '[JSON] AddRolesResponse + Post '[JSON] (AddRolesResponse Set) -- | Remove maintainers from the project. type RemoveRolesEndpoint = - ReqBody '[JSON] RemoveRolesRequest + ReqBody '[JSON] (RemoveRolesRequest Set) :> -- Return the updated list of maintainers - Delete '[JSON] RemoveRolesResponse + Delete '[JSON] (RemoveRolesResponse Set) type ProjectNotificationSubscriptionEndpoint = ReqBody '[JSON] ProjectNotificationSubscriptionRequest diff --git a/src/Share/Web/Share/Projects/Impl.hs b/src/Share/Web/Share/Projects/Impl.hs index 9da2a4ef..b89e617e 100644 --- a/src/Share/Web/Share/Projects/Impl.hs +++ b/src/Share/Web/Share/Projects/Impl.hs @@ -386,7 +386,7 @@ projectReadmeEndpoint session userHandle projectSlug = do Nothing -> getProjectBranchReadmeEndpoint session userHandle projectSlug defaultBranchShorthand Nothing Just release -> getProjectReleaseReadmeEndpoint session userHandle projectSlug release.version -listRolesEndpoint :: Maybe Session -> UserHandle -> ProjectSlug -> WebApp ListRolesResponse +listRolesEndpoint :: Maybe Session -> UserHandle -> ProjectSlug -> WebApp (ListRolesResponse Set) listRolesEndpoint session projectUserHandle projectSlug = do caller <- AuthN.requireAuthenticatedUser session projectId <- PG.runTransactionOrRespondError $ do @@ -399,7 +399,7 @@ listRolesEndpoint session projectUserHandle projectSlug = do pure (isPremiumProject, roleAssignments) pure $ ListRolesResponse {active = isPremiumProject, roleAssignments} -addRolesEndpoint :: Maybe Session -> UserHandle -> ProjectSlug -> AddRolesRequest -> WebApp AddRolesResponse +addRolesEndpoint :: Maybe Session -> UserHandle -> ProjectSlug -> (AddRolesRequest Set) -> WebApp (AddRolesResponse Set) addRolesEndpoint session projectUserHandle projectSlug (AddRolesRequest {roleAssignments}) = do caller <- AuthN.requireAuthenticatedUser session projectId <- PG.runTransactionOrRespondError $ do @@ -410,7 +410,7 @@ addRolesEndpoint session projectUserHandle projectSlug (AddRolesRequest {roleAss roleAssignments <- canonicalRoleAssignmentOrdering <$> displaySubjectsOf (traversed . traversed) updatedRoles pure $ AddRolesResponse {roleAssignments} -removeRolesEndpoint :: Maybe Session -> UserHandle -> ProjectSlug -> RemoveRolesRequest -> WebApp RemoveRolesResponse +removeRolesEndpoint :: Maybe Session -> UserHandle -> ProjectSlug -> (RemoveRolesRequest Set) -> WebApp (RemoveRolesResponse Set) removeRolesEndpoint session projectUserHandle projectSlug (RemoveRolesRequest {roleAssignments}) = do caller <- AuthN.requireAuthenticatedUser session projectId <- PG.runTransactionOrRespondError $ do diff --git a/src/Share/Web/Share/Roles.hs b/src/Share/Web/Share/Roles.hs index 7e068cbb..2aef8ede 100644 --- a/src/Share/Web/Share/Roles.hs +++ b/src/Share/Web/Share/Roles.hs @@ -9,7 +9,7 @@ import Share.Web.Authorization.Types import Share.Web.Share.DisplayInfo.Types (OrgDisplayInfo (..), TeamDisplayInfo (..), UserDisplayInfo (..)) -- | The ordering isn't necessary logic, it just makes transcript tests much easier. -canonicalRoleAssignmentOrdering :: [RoleAssignment DisplayAuthSubject] -> [RoleAssignment DisplayAuthSubject] +canonicalRoleAssignmentOrdering :: (Ord (f RoleRef)) => [RoleAssignment f DisplayAuthSubject] -> [RoleAssignment f DisplayAuthSubject] canonicalRoleAssignmentOrdering = List.sortOn \(RoleAssignment {roles, subject}) -> case subject of diff --git a/transcripts/share-apis/orgs/org-add-members-new-user.json b/transcripts/share-apis/orgs/org-add-members-new-user.json new file mode 100644 index 00000000..1804d86f --- /dev/null +++ b/transcripts/share-apis/orgs/org-add-members-new-user.json @@ -0,0 +1,38 @@ +{ + "body": { + "members": [ + { + "role": "org_maintainer", + "subject": { + "avatarUrl": null, + "handle": "newuser", + "name": null, + "userId": "U-" + } + }, + { + "role": "org_maintainer", + "subject": { + "avatarUrl": null, + "handle": "test", + "name": null, + "userId": "U-" + } + }, + { + "role": "org_owner", + "subject": { + "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", + "handle": "transcripts", + "name": "The Transcript User", + "userId": "U-" + } + } + ] + }, + "status": [ + { + "status_code": 200 + } + ] +} diff --git a/transcripts/share-apis/orgs/org-add-members.json b/transcripts/share-apis/orgs/org-add-members.json index 31568ab1..778ecfaa 100644 --- a/transcripts/share-apis/orgs/org-add-members.json +++ b/transcripts/share-apis/orgs/org-add-members.json @@ -2,22 +2,22 @@ "body": { "members": [ { - "avatarUrl": null, - "handle": "newuser", - "name": null, - "userId": "U-" + "role": "org_maintainer", + "subject": { + "avatarUrl": null, + "handle": "test", + "name": null, + "userId": "U-" + } }, { - "avatarUrl": null, - "handle": "test", - "name": null, - "userId": "U-" - }, - { - "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", - "handle": "transcripts", - "name": "The Transcript User", - "userId": "U-" + "role": "org_owner", + "subject": { + "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", + "handle": "transcripts", + "name": "The Transcript User", + "userId": "U-" + } } ] }, diff --git a/transcripts/share-apis/orgs/org-get-members-after-adding.json b/transcripts/share-apis/orgs/org-get-members-after-adding.json index 31568ab1..1804d86f 100644 --- a/transcripts/share-apis/orgs/org-get-members-after-adding.json +++ b/transcripts/share-apis/orgs/org-get-members-after-adding.json @@ -2,22 +2,31 @@ "body": { "members": [ { - "avatarUrl": null, - "handle": "newuser", - "name": null, - "userId": "U-" + "role": "org_maintainer", + "subject": { + "avatarUrl": null, + "handle": "newuser", + "name": null, + "userId": "U-" + } }, { - "avatarUrl": null, - "handle": "test", - "name": null, - "userId": "U-" + "role": "org_maintainer", + "subject": { + "avatarUrl": null, + "handle": "test", + "name": null, + "userId": "U-" + } }, { - "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", - "handle": "transcripts", - "name": "The Transcript User", - "userId": "U-" + "role": "org_owner", + "subject": { + "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", + "handle": "transcripts", + "name": "The Transcript User", + "userId": "U-" + } } ] }, diff --git a/transcripts/share-apis/orgs/org-get-members-after-removing.json b/transcripts/share-apis/orgs/org-get-members-after-removing.json index af22f1bc..778ecfaa 100644 --- a/transcripts/share-apis/orgs/org-get-members-after-removing.json +++ b/transcripts/share-apis/orgs/org-get-members-after-removing.json @@ -2,16 +2,22 @@ "body": { "members": [ { - "avatarUrl": null, - "handle": "test", - "name": null, - "userId": "U-" + "role": "org_maintainer", + "subject": { + "avatarUrl": null, + "handle": "test", + "name": null, + "userId": "U-" + } }, { - "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", - "handle": "transcripts", - "name": "The Transcript User", - "userId": "U-" + "role": "org_owner", + "subject": { + "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", + "handle": "transcripts", + "name": "The Transcript User", + "userId": "U-" + } } ] }, diff --git a/transcripts/share-apis/orgs/org-get-members-public.json b/transcripts/share-apis/orgs/org-get-members-public.json index af22f1bc..778ecfaa 100644 --- a/transcripts/share-apis/orgs/org-get-members-public.json +++ b/transcripts/share-apis/orgs/org-get-members-public.json @@ -2,16 +2,22 @@ "body": { "members": [ { - "avatarUrl": null, - "handle": "test", - "name": null, - "userId": "U-" + "role": "org_maintainer", + "subject": { + "avatarUrl": null, + "handle": "test", + "name": null, + "userId": "U-" + } }, { - "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", - "handle": "transcripts", - "name": "The Transcript User", - "userId": "U-" + "role": "org_owner", + "subject": { + "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", + "handle": "transcripts", + "name": "The Transcript User", + "userId": "U-" + } } ] }, diff --git a/transcripts/share-apis/orgs/org-remove-members.json b/transcripts/share-apis/orgs/org-remove-members.json index af22f1bc..778ecfaa 100644 --- a/transcripts/share-apis/orgs/org-remove-members.json +++ b/transcripts/share-apis/orgs/org-remove-members.json @@ -2,16 +2,22 @@ "body": { "members": [ { - "avatarUrl": null, - "handle": "test", - "name": null, - "userId": "U-" + "role": "org_maintainer", + "subject": { + "avatarUrl": null, + "handle": "test", + "name": null, + "userId": "U-" + } }, { - "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", - "handle": "transcripts", - "name": "The Transcript User", - "userId": "U-" + "role": "org_owner", + "subject": { + "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", + "handle": "transcripts", + "name": "The Transcript User", + "userId": "U-" + } } ] }, diff --git a/transcripts/share-apis/roles/org-remove-only-owner.json b/transcripts/share-apis/orgs/org-remove-only-owner.json similarity index 100% rename from transcripts/share-apis/roles/org-remove-only-owner.json rename to transcripts/share-apis/orgs/org-remove-only-owner.json diff --git a/transcripts/share-apis/orgs/run.zsh b/transcripts/share-apis/orgs/run.zsh index 35b17ca7..8273f3a0 100755 --- a/transcripts/share-apis/orgs/run.zsh +++ b/transcripts/share-apis/orgs/run.zsh @@ -50,7 +50,9 @@ fetch "$admin_user" POST org-create-by-admin-commercial '/orgs' '{ # Owner can add members fetch "$transcripts_user" POST org-add-members '/orgs/acme/members' '{ "members": [ - "test" + { "subject": "test", + "role": "org_maintainer" + } ] }' @@ -65,20 +67,26 @@ fetch "$unauthorized_user" GET org-get-members-public '/orgs/acme/members' # Members without permission can't edit members lists. fetch "$unauthorized_user" POST org-add-members-unauthorized '/orgs/acme/members' '{ "members": [ - "unauthorized" + { "subject": "unauthorized", + "role": "org_maintainer" + } ] }' -fetch "$transcripts_user" POST org-add-members '/orgs/acme/members' '{ +fetch "$transcripts_user" POST org-add-members-new-user '/orgs/acme/members' '{ "members": [ - "newuser" + { "subject": "newuser", + "role": "org_maintainer" + } ] }' # Can't add an org to another org fetch "$transcripts_user" POST org-cant-have-org-members '/orgs/acme/members' '{ "members": [ - "unison" + { "subject": "unison", + "role": "org_maintainer" + } ] }' @@ -91,9 +99,17 @@ fetch "$transcripts_user" DELETE org-remove-members '/orgs/acme/members' '{ ] }' +# Cannot remove the only owner +fetch "$transcripts_user" DELETE org-remove-only-owner '/orgs/acme/members' '{ + "members": [ + "transcripts" + ] +}' + fetch "$transcripts_user" GET org-get-members-after-removing '/orgs/acme/members' # Create projects in each org. +login_user_for_ucm 'transcripts' transcript_ucm transcript create-org-projects.md # Get projects for each org diff --git a/transcripts/share-apis/roles/check-contributor-membership-addition.json b/transcripts/share-apis/roles/check-contributor-membership-addition.json deleted file mode 100644 index 6aed5b16..00000000 --- a/transcripts/share-apis/roles/check-contributor-membership-addition.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "body": { - "members": [ - { - "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", - "handle": "admin", - "name": "Admin User", - "userId": "U-" - }, - { - "avatarUrl": null, - "handle": "some-user", - "name": null, - "userId": "U-" - }, - { - "avatarUrl": null, - "handle": "test", - "name": null, - "userId": "U-" - }, - { - "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", - "handle": "transcripts", - "name": "The Transcript User", - "userId": "U-" - } - ] - }, - "status": [ - { - "status_code": 200 - } - ] -} diff --git a/transcripts/share-apis/roles/check-contributor-membership-removal.json b/transcripts/share-apis/roles/check-contributor-membership-removal.json deleted file mode 100644 index 79bd2a4c..00000000 --- a/transcripts/share-apis/roles/check-contributor-membership-removal.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "body": { - "members": [ - { - "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", - "handle": "admin", - "name": "Admin User", - "userId": "U-" - }, - { - "avatarUrl": null, - "handle": "test", - "name": null, - "userId": "U-" - }, - { - "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", - "handle": "transcripts", - "name": "The Transcript User", - "userId": "U-" - } - ] - }, - "status": [ - { - "status_code": 200 - } - ] -} diff --git a/transcripts/share-apis/roles/grant-project-contributor.json b/transcripts/share-apis/roles/grant-project-contributor.json deleted file mode 100644 index e8977d64..00000000 --- a/transcripts/share-apis/roles/grant-project-contributor.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "body": { - "active": true, - "role_assignments": [ - { - "roles": [ - "project_contributor" - ], - "subject": { - "data": { - "avatarUrl": null, - "handle": "some-user", - "name": null, - "userId": "U-" - }, - "kind": "user" - } - }, - { - "roles": [ - "org_owner" - ], - "subject": { - "data": { - "avatarUrl": null, - "handle": "test", - "name": null, - "userId": "U-" - }, - "kind": "user" - } - }, - { - "roles": [ - "org_default" - ], - "subject": { - "data": { - "isCommercial": false, - "orgId": "ORG-", - "user": { - "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", - "handle": "unison", - "name": "Unison Org", - "userId": "U-" - } - }, - "kind": "org" - } - } - ] - }, - "status": [ - { - "status_code": 200 - } - ] -} diff --git a/transcripts/share-apis/roles/maintainer-project-view.json b/transcripts/share-apis/roles/maintainer-project-view.json deleted file mode 100644 index 7ae3f047..00000000 --- a/transcripts/share-apis/roles/maintainer-project-view.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "body": { - "createdAt": "", - "defaultBranch": "main", - "isFaved": false, - "isPremiumProject": false, - "isSubscribed": false, - "latestRelease": null, - "numActiveContributions": 0, - "numClosedContributions": 0, - "numClosedTickets": 0, - "numDraftContributions": 0, - "numFavs": 0, - "numMergedContributions": 0, - "numOpenTickets": 0, - "owner": { - "handle": "@unison", - "name": "Unison Org", - "type": "organization" - }, - "permissions": [ - "project:view", - "project:contribute", - "project:maintain", - "project:create", - "org:view", - "org:edit", - "team:view", - "notification_hub_entry:view", - "notification_hub_entry:update", - "notification_subscription:view", - "notification_subscription:manage", - "notification_delivery_method:view", - "notification_delivery_method:manage" - ], - "releaseDownloads": [], - "slug": "privateorgproject", - "summary": "Private Unison Project", - "tags": [], - "updatedAt": "", - "visibility": "private" - }, - "status": [ - { - "status_code": 200 - } - ] -} diff --git a/transcripts/share-apis/roles/non-maintainer-project-view.json b/transcripts/share-apis/roles/non-maintainer-project-view.json deleted file mode 100644 index 445a0a37..00000000 --- a/transcripts/share-apis/roles/non-maintainer-project-view.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "body": "Not Found: No project found", - "status": [ - { - "status_code": 404 - } - ] -} diff --git a/transcripts/share-apis/roles/org-roles-list.json b/transcripts/share-apis/roles/org-roles-list.json deleted file mode 100644 index e249a100..00000000 --- a/transcripts/share-apis/roles/org-roles-list.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "body": { - "active": true, - "role_assignments": [ - { - "roles": [ - "org_owner" - ], - "subject": { - "data": { - "avatarUrl": null, - "handle": "test", - "name": null, - "userId": "U-" - }, - "kind": "user" - } - }, - { - "roles": [ - "org_default" - ], - "subject": { - "data": { - "isCommercial": false, - "orgId": "ORG-", - "user": { - "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", - "handle": "unison", - "name": "Unison Org", - "userId": "U-" - } - }, - "kind": "org" - } - } - ] - }, - "status": [ - { - "status_code": 200 - } - ] -} diff --git a/transcripts/share-apis/roles/revoke-project-contributor.json b/transcripts/share-apis/roles/revoke-project-contributor.json deleted file mode 100644 index e249a100..00000000 --- a/transcripts/share-apis/roles/revoke-project-contributor.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "body": { - "active": true, - "role_assignments": [ - { - "roles": [ - "org_owner" - ], - "subject": { - "data": { - "avatarUrl": null, - "handle": "test", - "name": null, - "userId": "U-" - }, - "kind": "user" - } - }, - { - "roles": [ - "org_default" - ], - "subject": { - "data": { - "isCommercial": false, - "orgId": "ORG-", - "user": { - "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", - "handle": "unison", - "name": "Unison Org", - "userId": "U-" - } - }, - "kind": "org" - } - } - ] - }, - "status": [ - { - "status_code": 200 - } - ] -} diff --git a/transcripts/share-apis/roles/run.zsh b/transcripts/share-apis/roles/run.zsh deleted file mode 100755 index f3bfab3d..00000000 --- a/transcripts/share-apis/roles/run.zsh +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env zsh - -set -e - -source "../../transcript_helpers.sh" - -some_user="$(create_user some-user)" -read_maintainer="$(create_user read-maintainer)" -maintain_maintainer="$(create_user maintain-maintainer)" -admin_maintainer="$(create_user admin-maintainer)" - -# User without permission shouldn't be able to access private project. - -fetch "$some_user" GET non-maintainer-project-view '/users/unison/projects/privateorgproject' - -fetch "$test_user" GET org-roles-list '/orgs/unison/roles' - -# Org must always have an owner -fetch "$test_user" DELETE org-remove-only-owner '/orgs/unison/roles' " -{ - \"role_assignments\": - [ { \"subject\": {\"kind\": \"user\", \"id\": \"${test_user}\"} - , \"roles\": [\"org_owner\"] - } - ] -}" - -# Giving this user the project_contributor role on the Unison org should give them access to the project owned by that org. -# Typically you'd add the user to the org, but this is a good way to test JUST the resource role hierarchy. -fetch "$test_user" POST grant-project-contributor '/orgs/unison/roles' " -{ - \"role_assignments\": - [ { \"subject\": {\"kind\": \"user\", \"id\": \"${some_user}\"} - , \"roles\": [\"project_contributor\"] - } - ] -}" - -# Now the user should be a member of the org -fetch "$test_user" GET check-contributor-membership-addition '/orgs/unison/members' - -fetch "$some_user" GET maintainer-project-view '/users/unison/projects/privateorgproject' - -# Remove the user from the org again -fetch "$test_user" DELETE revoke-project-contributor '/orgs/unison/roles' " -{ - \"role_assignments\": - [ { \"subject\": {\"kind\": \"user\", \"id\": \"${some_user}\"} - , \"roles\": [\"project_contributor\"] - } - ] -}" - -fetch "$some_user" GET non-maintainer-project-view '/users/unison/projects/privateorgproject' - -# Now the user should be no longer be a member of the org -fetch "$test_user" GET check-contributor-membership-removal '/orgs/unison/members' diff --git a/transcripts/sql/inserts.sql b/transcripts/sql/inserts.sql index 6bb6d5bc..76d71b8d 100644 --- a/transcripts/sql/inserts.sql +++ b/transcripts/sql/inserts.sql @@ -3,7 +3,7 @@ DO $$ BEGIN IF NOT EXISTS ( - SELECT 1 FROM information_schema.tables + SELECT 1 FROM information_schema.tables WHERE table_name = 'is_local_database' ) THEN RAISE EXCEPTION 'Refusing to insert fixtures on non-local database.'; @@ -74,7 +74,7 @@ VALUES ( INSERT INTO orgs ( id, - user_id) + user_id) VALUES ( '7ab35ad5-6755-4dd1-9753-bc3ba6b88039', 'e5e7635c-8db2-4b7f-9fee-86ee8d120ef9' @@ -83,25 +83,30 @@ VALUES ( INSERT INTO org_members ( org_id, organization_user_id, - member_user_id) + member_user_id, + role_id +) VALUES ( '7ab35ad5-6755-4dd1-9753-bc3ba6b88039', 'e5e7635c-8db2-4b7f-9fee-86ee8d120ef9', - 'd32f4ddf-2423-4f10-a4de-465939951354'), + -- @test + 'd32f4ddf-2423-4f10-a4de-465939951354', + (SELECT r.id FROM roles r WHERE r.ref = 'org_owner') +), ( '7ab35ad5-6755-4dd1-9753-bc3ba6b88039', 'e5e7635c-8db2-4b7f-9fee-86ee8d120ef9', - '43efd5e7-139a-40b2-8a35-3f99b054dc84'), + -- @transcripts + '43efd5e7-139a-40b2-8a35-3f99b054dc84', + (SELECT r.id FROM roles r WHERE r.ref = 'org_maintainer') +), ( '7ab35ad5-6755-4dd1-9753-bc3ba6b88039', 'e5e7635c-8db2-4b7f-9fee-86ee8d120ef9', - 'fe8921ca-aee7-40a2-8020-241ca78f2a5c'); - --- Make 'test' the owner of the unison org -INSERT INTO role_memberships(subject_id, resource_id, role_id) - SELECT (SELECT u.subject_id FROM users u WHERE u.handle = 'test'), - (SELECT org.resource_id FROM orgs org JOIN users orgu ON org.user_id = orgu.id WHERE orgu.handle = 'unison'), - (SELECT r.id FROM roles r WHERE r.ref = 'org_owner'); + -- @admin + 'fe8921ca-aee7-40a2-8020-241ca78f2a5c', + (SELECT r.id FROM roles r WHERE r.ref = 'org_maintainer') +); INSERT INTO tours ( user_id,