From 03c500a364a644099560d69c78fbf2c0f937b5a2 Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 22 Sep 2025 12:16:37 -0700 Subject: [PATCH 01/21] Add explicit role to org_members --- ...2025-04-08_public_resource_permissions.sql | 3 +- sql/2025-09-22_org_membership_roles.sql | 47 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 sql/2025-09-22_org_membership_roles.sql 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..6c1368c9 --- /dev/null +++ b/sql/2025-09-22_org_membership_roles.sql @@ -0,0 +1,47 @@ +-- 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. + +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 name = 'org_maintainer' LIMIT 1); + +ALTER TABLE org_members + ALTER COLUMN role_id SET NOT NULL; + +-- 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 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, drp.resource_id, bp.permission + FROM direct_resource_permissions drp + JOIN resource_hierarchy rh ON bp.resource_id = rh.parent_resource_id +); + From f837a23d5d9a885ab4bd63531f2ee3e185f92a3a Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 22 Sep 2025 12:29:38 -0700 Subject: [PATCH 02/21] Remove Org Roles endpoints --- src/Share/Web/Share/Orgs/API.hs | 27 +------- src/Share/Web/Share/Orgs/Impl.hs | 107 +----------------------------- src/Share/Web/Share/Orgs/Types.hs | 4 +- 3 files changed, 4 insertions(+), 134 deletions(-) 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..22eb4e6e 100644 --- a/src/Share/Web/Share/Orgs/Impl.hs +++ b/src/Share/Web/Share/Orgs/Impl.hs @@ -54,8 +54,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 +65,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 +80,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 @@ -169,47 +108,3 @@ removeMembersEndpoint orgHandle caller (OrgMembersRemoveRequest {members}) = 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 (||) diff --git a/src/Share/Web/Share/Orgs/Types.hs b/src/Share/Web/Share/Orgs/Types.hs index 6e2a1707..84060a66 100644 --- a/src/Share/Web/Share/Orgs/Types.hs +++ b/src/Share/Web/Share/Orgs/Types.hs @@ -57,7 +57,7 @@ instance FromJSON CreateOrgRequest where pure CreateOrgRequest {..} data OrgMembersAddRequest = OrgMembersAddRequest - { members :: [UserHandle] + { members :: [RoleAssignment UserHandle] } deriving (Show, Eq) @@ -73,7 +73,7 @@ instance FromJSON OrgMembersAddRequest where pure OrgMembersAddRequest {..} data OrgMembersListResponse = OrgMembersListResponse - { members :: [UserDisplayInfo] + { members :: [RoleAssignment UserDisplayInfo] } deriving (Show, Eq) From ebe61d1984e1a58e06e5e21b8a6f01035f0d0b42 Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 22 Sep 2025 12:37:49 -0700 Subject: [PATCH 03/21] Generalize RoleAssignment type over its container --- src/Share/Postgres/Projects/Queries.hs | 6 +-- src/Share/Web/Authorization/Types.hs | 68 +++++++++++++++----------- src/Share/Web/Share/Orgs/Impl.hs | 6 --- src/Share/Web/Share/Orgs/Queries.hs | 12 ++--- src/Share/Web/Share/Orgs/Types.hs | 7 +-- src/Share/Web/Share/Projects/API.hs | 11 +++-- src/Share/Web/Share/Projects/Impl.hs | 6 +-- src/Share/Web/Share/Roles.hs | 2 +- 8 files changed, 63 insertions(+), 55 deletions(-) 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/Web/Authorization/Types.hs b/src/Share/Web/Authorization/Types.hs index 0b5fe3fd..359c4d5d 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,20 +341,26 @@ 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 (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 (FromJSON user, FromJSON (f RoleRef)) => FromJSON (RoleAssignment f user) where parseJSON = Aeson.withObject "RoleAssignment" $ \o -> do subject <- o Aeson..: "subject" roles <- o Aeson..: "roles" @@ -385,84 +394,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/Impl.hs b/src/Share/Web/Share/Orgs/Impl.hs index 22eb4e6e..90421919 100644 --- a/src/Share/Web/Share/Orgs/Impl.hs +++ b/src/Share/Web/Share/Orgs/Impl.hs @@ -2,9 +2,6 @@ module Share.Web.Share.Orgs.Impl (server) where -import Control.Lens -import Data.Either (isRight) -import Data.Map qualified as Map import Data.Set qualified as Set import Servant import Servant.Server.Generic @@ -16,15 +13,12 @@ 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.Errors import Share.Web.Share.DisplayInfo.Types (OrgDisplayInfo) 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 diff --git a/src/Share/Web/Share/Orgs/Queries.hs b/src/Share/Web/Share/Orgs/Queries.hs index 8805b2fd..38e8a838 100644 --- a/src/Share/Web/Share/Orgs/Queries.hs +++ b/src/Share/Web/Share/Orgs/Queries.hs @@ -93,7 +93,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 +107,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 +130,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,11 +154,11 @@ removeOrgRoles orgId toRemove = do |] listOrgRoles orgId -listOrgMembers :: OrgId -> Transaction e [UserDisplayInfo] +listOrgMembers :: OrgId -> Transaction e [RoleAssignment Identity UserDisplayInfo] listOrgMembers orgId = do - queryListRows + queryListRows @(UserDisplayInfo, [RoleRef]) [sql| - SELECT u.handle, u.name, u.avatar_url, u.id + SELECT u.handle, u.name, u.avatar_url, u.id, array_agg(role.ref :: role_ref) as role_refs FROM org_members om JOIN users u ON om.member_user_id = u.id WHERE om.org_id = #{orgId} diff --git a/src/Share/Web/Share/Orgs/Types.hs b/src/Share/Web/Share/Orgs/Types.hs index 84060a66..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 :: [RoleAssignment UserHandle] + { members :: [RoleAssignment Identity UserHandle] } deriving (Show, Eq) @@ -73,7 +74,7 @@ instance FromJSON OrgMembersAddRequest where pure OrgMembersAddRequest {..} data OrgMembersListResponse = OrgMembersListResponse - { members :: [RoleAssignment 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 From 3542b04d28a697b4f2f7ae3f7bc34666452781dd Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 22 Sep 2025 12:55:24 -0700 Subject: [PATCH 04/21] Update listOrgMembers query --- src/Share/Web/Share/Orgs/Queries.hs | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/Share/Web/Share/Orgs/Queries.hs b/src/Share/Web/Share/Orgs/Queries.hs index 38e8a838..254c55a3 100644 --- a/src/Share/Web/Share/Orgs/Queries.hs +++ b/src/Share/Web/Share/Orgs/Queries.hs @@ -156,21 +156,27 @@ removeOrgRoles orgId toRemove = do listOrgMembers :: OrgId -> Transaction e [RoleAssignment Identity UserDisplayInfo] listOrgMembers orgId = do - queryListRows @(UserDisplayInfo, [RoleRef]) + queryListRows @((UserHandle, Maybe Text, Maybe URIParam, UserId) :. Only RoleRef) [sql| - SELECT u.handle, u.name, u.avatar_url, u.id, array_agg(role.ref :: role_ref) as role_refs + 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 newMembers = do From d114bb7381c311433757e77fb3cb7348d2b4956c Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 22 Sep 2025 13:04:35 -0700 Subject: [PATCH 05/21] Add foldMapMOf to prelude --- src/Share/Prelude.hs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 From 0b46f197861003dad7bbda30418ae44726afc0b0 Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 22 Sep 2025 13:04:35 -0700 Subject: [PATCH 06/21] Fix org members queries --- src/Share/Web/Share/Orgs/Impl.hs | 19 ++++++++++++++----- src/Share/Web/Share/Orgs/Operations.hs | 12 +++++------- src/Share/Web/Share/Orgs/Queries.hs | 15 +++++++++------ 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/Share/Web/Share/Orgs/Impl.hs b/src/Share/Web/Share/Orgs/Impl.hs index 90421919..ad684b15 100644 --- a/src/Share/Web/Share/Orgs/Impl.hs +++ b/src/Share/Web/Share/Orgs/Impl.hs @@ -2,6 +2,9 @@ module Share.Web.Share.Orgs.Impl (server) where +import Control.Lens +import Data.Map qualified as Map +import Data.Monoid (Any (..)) import Data.Set qualified as Set import Servant import Servant.Server.Generic @@ -13,6 +16,7 @@ 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 (RoleAssignment (..)) import Share.Web.Errors import Share.Web.Share.DisplayInfo.Types (OrgDisplayInfo) import Share.Web.Share.Orgs.API as API @@ -86,12 +90,17 @@ 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 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 254c55a3..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 @@ -178,16 +179,18 @@ listOrgMembers orgId = do 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 |] From aebf7931410b35a8d36ef7ad6724201b8e170f5a Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 22 Sep 2025 13:25:09 -0700 Subject: [PATCH 07/21] Add trigger to enforce every org has an owner --- sql/2025-09-22_org_membership_roles.sql | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/sql/2025-09-22_org_membership_roles.sql b/sql/2025-09-22_org_membership_roles.sql index 6c1368c9..4bc33fa5 100644 --- a/sql/2025-09-22_org_membership_roles.sql +++ b/sql/2025-09-22_org_membership_roles.sql @@ -8,9 +8,43 @@ ALTER TABLE org_members UPDATE org_members SET role_id = (SELECT id FROM roles WHERE name = 'org_maintainer' LIMIT 1); +-- Elevate the current org owners to have the org_owner role +UPDATE org_members + SET role_id = 'org_owner' + 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 name = '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 From 5fe4c9bbfd8ad3219ecfab3b4ff77b3c17a725d7 Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 22 Sep 2025 13:50:09 -0700 Subject: [PATCH 08/21] Check that there's still an howner when removing org members --- src/Share/Web/Share/Orgs/Impl.hs | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/Share/Web/Share/Orgs/Impl.hs b/src/Share/Web/Share/Orgs/Impl.hs index ad684b15..6b8cf0ad 100644 --- a/src/Share/Web/Share/Orgs/Impl.hs +++ b/src/Share/Web/Share/Orgs/Impl.hs @@ -17,13 +17,13 @@ import Share.Utils.Logging qualified as Logging import Share.Web.App import Share.Web.Authorization qualified as AuthZ 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 Unison.Util.Set qualified as Set data OrgError = OrgMemberOfOrgError @@ -107,7 +107,15 @@ removeMembersEndpoint :: UserHandle -> UserId -> OrgMembersRemoveRequest -> WebA 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 + 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) From 7f623adb9c4fa466a8f475838f62a892af0295fa Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 22 Sep 2025 13:51:49 -0700 Subject: [PATCH 09/21] Remove org roles routes from share client --- share-client/src/Share/Client/Orgs.hs | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) 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 From 6fd2c55599e8d4109bcd820e532aba00fc2bbfdc Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 22 Sep 2025 14:00:59 -0700 Subject: [PATCH 10/21] Remove unused roles transcripts --- ...check-contributor-membership-addition.json | 35 ----------- .../check-contributor-membership-removal.json | 29 ---------- .../roles/grant-project-contributor.json | 58 ------------------- .../roles/maintainer-project-view.json | 48 --------------- .../roles/non-maintainer-project-view.json | 8 --- .../roles/org-remove-only-owner.json | 8 --- .../share-apis/roles/org-roles-list.json | 44 -------------- .../roles/revoke-project-contributor.json | 44 -------------- transcripts/share-apis/roles/run.zsh | 57 ------------------ 9 files changed, 331 deletions(-) delete mode 100644 transcripts/share-apis/roles/check-contributor-membership-addition.json delete mode 100644 transcripts/share-apis/roles/check-contributor-membership-removal.json delete mode 100644 transcripts/share-apis/roles/grant-project-contributor.json delete mode 100644 transcripts/share-apis/roles/maintainer-project-view.json delete mode 100644 transcripts/share-apis/roles/non-maintainer-project-view.json delete mode 100644 transcripts/share-apis/roles/org-remove-only-owner.json delete mode 100644 transcripts/share-apis/roles/org-roles-list.json delete mode 100644 transcripts/share-apis/roles/revoke-project-contributor.json delete mode 100755 transcripts/share-apis/roles/run.zsh 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-remove-only-owner.json b/transcripts/share-apis/roles/org-remove-only-owner.json deleted file mode 100644 index 73ef913c..00000000 --- a/transcripts/share-apis/roles/org-remove-only-owner.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "body": "Cannot remove the only owner of an org.", - "status": [ - { - "status_code": 400 - } - ] -} 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' From 7cf60a7c2a0b401a31e45c39a0cb14be16e003b9 Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 22 Sep 2025 14:01:34 -0700 Subject: [PATCH 11/21] Add target for running transcript docker deps --- Makefile | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 From 1b7c302e8a9713174716039faed228798529a73b Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 22 Sep 2025 14:01:34 -0700 Subject: [PATCH 12/21] Fix up migration sql syntax --- sql/2025-09-22_org_membership_roles.sql | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/sql/2025-09-22_org_membership_roles.sql b/sql/2025-09-22_org_membership_roles.sql index 4bc33fa5..69f64d06 100644 --- a/sql/2025-09-22_org_membership_roles.sql +++ b/sql/2025-09-22_org_membership_roles.sql @@ -6,11 +6,11 @@ ALTER TABLE org_members -- set all existing org members to be maintiners UPDATE org_members - SET role_id = (SELECT id FROM roles WHERE name = 'org_maintainer' LIMIT 1); + 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 = 'org_owner' + SET role_id = (SELECT id FROM roles WHERE ref = 'org_owner' LIMIT 1) WHERE EXISTS ( SELECT FROM orgs org @@ -58,6 +58,7 @@ CREATE OR REPLACE VIEW direct_resource_permissions(subject_id, resource_id, perm -- 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 @@ -65,7 +66,7 @@ CREATE OR REPLACE VIEW direct_resource_permissions(subject_id, resource_id, perm -- 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 @@ -74,8 +75,8 @@ CREATE OR REPLACE VIEW subject_resource_permissions(subject_id, resource_id, per FROM direct_resource_permissions drp UNION -- Inherit permissions from parent resources - SELECT drp.subject_id, drp.resource_id, bp.permission + SELECT drp.subject_id, drp.resource_id, drp.permission FROM direct_resource_permissions drp - JOIN resource_hierarchy rh ON bp.resource_id = rh.parent_resource_id + JOIN resource_hierarchy rh ON drp.resource_id = rh.parent_resource_id ); From df7d8bcceae5e8c597f470353ef05976f5c0d02c Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 22 Sep 2025 14:01:34 -0700 Subject: [PATCH 13/21] Fix org members in inserts.sql --- transcripts/sql/inserts.sql | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) 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, From 872beb9ce9c1e81874042014ab980ec797816951 Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 22 Sep 2025 14:01:34 -0700 Subject: [PATCH 14/21] Fix bad column ref in new validation trigger --- sql/2025-09-22_org_membership_roles.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql/2025-09-22_org_membership_roles.sql b/sql/2025-09-22_org_membership_roles.sql index 69f64d06..f43506d2 100644 --- a/sql/2025-09-22_org_membership_roles.sql +++ b/sql/2025-09-22_org_membership_roles.sql @@ -32,7 +32,7 @@ BEGIN SELECT FROM org_members om WHERE om.org_id = OLD.org_id - AND om.role_id = (SELECT id FROM roles WHERE name = 'org_owner' LIMIT 1) + 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; From 2534d7870e9efd177ad38e41a4865c445e19b283 Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 22 Sep 2025 14:01:34 -0700 Subject: [PATCH 15/21] Fix up role permissions for project creation --- sql/2025-09-22_org_membership_roles.sql | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/sql/2025-09-22_org_membership_roles.sql b/sql/2025-09-22_org_membership_roles.sql index f43506d2..eaf06ae9 100644 --- a/sql/2025-09-22_org_membership_roles.sql +++ b/sql/2025-09-22_org_membership_roles.sql @@ -1,6 +1,23 @@ -- 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; @@ -80,3 +97,5 @@ CREATE OR REPLACE VIEW subject_resource_permissions(subject_id, resource_id, per JOIN resource_hierarchy rh ON drp.resource_id = rh.parent_resource_id ); + +-- TODO: delete now redundant roles from role_memberships From b76ec328105063fc981496d3899952d37f069dc2 Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 22 Sep 2025 14:01:34 -0700 Subject: [PATCH 16/21] Fix super subtle permissions typo --- sql/2025-09-22_org_membership_roles.sql | 28 ++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/sql/2025-09-22_org_membership_roles.sql b/sql/2025-09-22_org_membership_roles.sql index eaf06ae9..708720d3 100644 --- a/sql/2025-09-22_org_membership_roles.sql +++ b/sql/2025-09-22_org_membership_roles.sql @@ -2,21 +2,21 @@ -- 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', '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' - ; +-- 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' - ; +-- -- 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; @@ -92,7 +92,7 @@ CREATE OR REPLACE VIEW subject_resource_permissions(subject_id, resource_id, per FROM direct_resource_permissions drp UNION -- Inherit permissions from parent resources - SELECT drp.subject_id, drp.resource_id, drp.permission + 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 ); From 80fb971fa4f6312f3a1828de7b41cf32c0ed110b Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 22 Sep 2025 14:01:34 -0700 Subject: [PATCH 17/21] Rerun org transcripts --- .../orgs/org-add-members-new-user.json | 38 +++++++++++++++++++ .../share-apis/orgs/org-add-members.json | 28 +++++++------- .../orgs/org-get-members-after-adding.json | 33 ++++++++++------ .../orgs/org-get-members-after-removing.json | 22 +++++++---- .../orgs/org-get-members-public.json | 22 +++++++---- .../share-apis/orgs/org-remove-members.json | 19 +--------- transcripts/share-apis/orgs/run.zsh | 26 ++++++++++--- 7 files changed, 124 insertions(+), 64 deletions(-) create mode 100644 transcripts/share-apis/orgs/org-add-members-new-user.json 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..9bc679c0 --- /dev/null +++ b/transcripts/share-apis/orgs/org-add-members-new-user.json @@ -0,0 +1,38 @@ +{ + "body": { + "members": [ + { + "roles": "org_maintainer", + "subject": { + "avatarUrl": null, + "handle": "newuser", + "name": null, + "userId": "U-" + } + }, + { + "roles": "org_maintainer", + "subject": { + "avatarUrl": null, + "handle": "test", + "name": null, + "userId": "U-" + } + }, + { + "roles": "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..6e6efdcc 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-" + "roles": "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-" + "roles": "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..9bc679c0 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-" + "roles": "org_maintainer", + "subject": { + "avatarUrl": null, + "handle": "newuser", + "name": null, + "userId": "U-" + } }, { - "avatarUrl": null, - "handle": "test", - "name": null, - "userId": "U-" + "roles": "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-" + "roles": "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..6e6efdcc 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-" + "roles": "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-" + "roles": "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..6e6efdcc 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-" + "roles": "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-" + "roles": "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..73ef913c 100644 --- a/transcripts/share-apis/orgs/org-remove-members.json +++ b/transcripts/share-apis/orgs/org-remove-members.json @@ -1,23 +1,8 @@ { - "body": { - "members": [ - { - "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-" - } - ] - }, + "body": "Cannot remove the only owner of an org.", "status": [ { - "status_code": 200 + "status_code": 400 } ] } diff --git a/transcripts/share-apis/orgs/run.zsh b/transcripts/share-apis/orgs/run.zsh index 35b17ca7..204e6ec3 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", + "roles": "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", + "roles": "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", + "roles": "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", + "roles": "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-members '/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 From 369a93cc2f4535accffb9ce511241717c999132c Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 22 Sep 2025 15:15:27 -0700 Subject: [PATCH 18/21] Fix up naming of transcript artifacts --- .../share-apis/orgs/org-remove-members.json | 25 +++++++++++++++++-- .../orgs/org-remove-only-owner.json | 8 ++++++ transcripts/share-apis/orgs/run.zsh | 2 +- 3 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 transcripts/share-apis/orgs/org-remove-only-owner.json diff --git a/transcripts/share-apis/orgs/org-remove-members.json b/transcripts/share-apis/orgs/org-remove-members.json index 73ef913c..6e6efdcc 100644 --- a/transcripts/share-apis/orgs/org-remove-members.json +++ b/transcripts/share-apis/orgs/org-remove-members.json @@ -1,8 +1,29 @@ { - "body": "Cannot remove the only owner of an org.", + "body": { + "members": [ + { + "roles": "org_maintainer", + "subject": { + "avatarUrl": null, + "handle": "test", + "name": null, + "userId": "U-" + } + }, + { + "roles": "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": 400 + "status_code": 200 } ] } diff --git a/transcripts/share-apis/orgs/org-remove-only-owner.json b/transcripts/share-apis/orgs/org-remove-only-owner.json new file mode 100644 index 00000000..73ef913c --- /dev/null +++ b/transcripts/share-apis/orgs/org-remove-only-owner.json @@ -0,0 +1,8 @@ +{ + "body": "Cannot remove the only owner of an org.", + "status": [ + { + "status_code": 400 + } + ] +} diff --git a/transcripts/share-apis/orgs/run.zsh b/transcripts/share-apis/orgs/run.zsh index 204e6ec3..c4eb2296 100755 --- a/transcripts/share-apis/orgs/run.zsh +++ b/transcripts/share-apis/orgs/run.zsh @@ -100,7 +100,7 @@ fetch "$transcripts_user" DELETE org-remove-members '/orgs/acme/members' '{ }' # Cannot remove the only owner -fetch "$transcripts_user" DELETE org-remove-members '/orgs/acme/members' '{ +fetch "$transcripts_user" DELETE org-remove-only-owner '/orgs/acme/members' '{ "members": [ "transcripts" ] From 89c6fe95923e0ff263ff4ae319f790cc7b78f081 Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 22 Sep 2025 15:23:08 -0700 Subject: [PATCH 19/21] Delete old unused role memberships --- sql/2025-09-22_org_membership_roles.sql | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/sql/2025-09-22_org_membership_roles.sql b/sql/2025-09-22_org_membership_roles.sql index 708720d3..023525d3 100644 --- a/sql/2025-09-22_org_membership_roles.sql +++ b/sql/2025-09-22_org_membership_roles.sql @@ -1,4 +1,4 @@ --- Org membership is now associated with a specific role within the org, this simplifies things, +-- 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 @@ -22,7 +22,7 @@ ALTER TABLE org_members ADD COLUMN role_id UUID REFERENCES roles(id) NULL; -- set all existing org members to be maintiners -UPDATE org_members +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 @@ -59,7 +59,7 @@ $$ LANGUAGE plpgsql; CREATE TRIGGER check_org_owners_trigger AFTER UPDATE OR DELETE ON org_members - FOR EACH ROW + FOR EACH ROW EXECUTE FUNCTION check_orgs_have_an_owner(); -- Split out view containing all the direct subject<->resource permissions. @@ -88,7 +88,7 @@ CREATE OR REPLACE VIEW direct_resource_permissions(subject_id, resource_id, perm -- 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 + SELECT drp.subject_id, drp.resource_id, drp.permission FROM direct_resource_permissions drp UNION -- Inherit permissions from parent resources @@ -98,4 +98,8 @@ CREATE OR REPLACE VIEW subject_resource_permissions(subject_id, resource_id, per ); --- TODO: delete now redundant roles from role_memberships +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'); From 0c65bf7513e4aa6124ad1dae3d2b625bad93fbae Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 22 Sep 2025 15:34:39 -0700 Subject: [PATCH 20/21] Use correct 'role' or 'roles' delineation in JSON --- src/Share/Web/Authorization/Types.hs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/Share/Web/Authorization/Types.hs b/src/Share/Web/Authorization/Types.hs index 359c4d5d..4853c70d 100644 --- a/src/Share/Web/Authorization/Types.hs +++ b/src/Share/Web/Authorization/Types.hs @@ -353,19 +353,33 @@ deriving instance (Ord (f RoleRef), Ord subject) => Ord (RoleAssignment f subjec deriving instance (Show (f RoleRef), Show subject) => Show (RoleAssignment f subject) -instance (ToJSON user, ToJSON (f RoleRef)) => ToJSON (RoleAssignment f user) where +instance (ToJSON user) => ToJSON (RoleAssignment Identity user) where + toJSON RoleAssignment {..} = + object + [ "subject" Aeson..= subject, + "role" Aeson..= roles + ] + +instance (ToJSON user) => ToJSON (RoleAssignment Set user) where toJSON RoleAssignment {..} = object [ "subject" Aeson..= subject, "roles" Aeson..= roles ] -instance (FromJSON user, FromJSON (f RoleRef)) => FromJSON (RoleAssignment f user) where +instance (FromJSON user) => FromJSON (RoleAssignment Identity user) where + parseJSON = Aeson.withObject "RoleAssignment" $ \o -> do + subject <- o Aeson..: "subject" + roles <- o Aeson..: "role" + pure RoleAssignment {..} + +instance (FromJSON user) => FromJSON (RoleAssignment Set 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) From 533744a8c8316ec7f8129c854e8ddf0367e9d7e6 Mon Sep 17 00:00:00 2001 From: Chris Penner Date: Mon, 22 Sep 2025 15:38:12 -0700 Subject: [PATCH 21/21] roles -> role --- src/Share/Web/Authorization/Types.hs | 8 ++++---- transcripts/share-apis/orgs/org-add-members-new-user.json | 6 +++--- transcripts/share-apis/orgs/org-add-members.json | 4 ++-- .../share-apis/orgs/org-get-members-after-adding.json | 6 +++--- .../share-apis/orgs/org-get-members-after-removing.json | 4 ++-- transcripts/share-apis/orgs/org-get-members-public.json | 4 ++-- transcripts/share-apis/orgs/org-remove-members.json | 4 ++-- transcripts/share-apis/orgs/run.zsh | 8 ++++---- 8 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/Share/Web/Authorization/Types.hs b/src/Share/Web/Authorization/Types.hs index 4853c70d..72a3d877 100644 --- a/src/Share/Web/Authorization/Types.hs +++ b/src/Share/Web/Authorization/Types.hs @@ -353,27 +353,27 @@ deriving instance (Ord (f RoleRef), Ord subject) => Ord (RoleAssignment f subjec deriving instance (Show (f RoleRef), Show subject) => Show (RoleAssignment f subject) -instance (ToJSON user) => ToJSON (RoleAssignment Identity user) where +instance {-# OVERLAPPING #-} (ToJSON user) => ToJSON (RoleAssignment Identity user) where toJSON RoleAssignment {..} = object [ "subject" Aeson..= subject, "role" Aeson..= roles ] -instance (ToJSON user) => ToJSON (RoleAssignment Set user) where +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 Identity 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 (FromJSON user) => FromJSON (RoleAssignment Set user) where +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" diff --git a/transcripts/share-apis/orgs/org-add-members-new-user.json b/transcripts/share-apis/orgs/org-add-members-new-user.json index 9bc679c0..1804d86f 100644 --- a/transcripts/share-apis/orgs/org-add-members-new-user.json +++ b/transcripts/share-apis/orgs/org-add-members-new-user.json @@ -2,7 +2,7 @@ "body": { "members": [ { - "roles": "org_maintainer", + "role": "org_maintainer", "subject": { "avatarUrl": null, "handle": "newuser", @@ -11,7 +11,7 @@ } }, { - "roles": "org_maintainer", + "role": "org_maintainer", "subject": { "avatarUrl": null, "handle": "test", @@ -20,7 +20,7 @@ } }, { - "roles": "org_owner", + "role": "org_owner", "subject": { "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", "handle": "transcripts", diff --git a/transcripts/share-apis/orgs/org-add-members.json b/transcripts/share-apis/orgs/org-add-members.json index 6e6efdcc..778ecfaa 100644 --- a/transcripts/share-apis/orgs/org-add-members.json +++ b/transcripts/share-apis/orgs/org-add-members.json @@ -2,7 +2,7 @@ "body": { "members": [ { - "roles": "org_maintainer", + "role": "org_maintainer", "subject": { "avatarUrl": null, "handle": "test", @@ -11,7 +11,7 @@ } }, { - "roles": "org_owner", + "role": "org_owner", "subject": { "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", "handle": "transcripts", 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 9bc679c0..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,7 +2,7 @@ "body": { "members": [ { - "roles": "org_maintainer", + "role": "org_maintainer", "subject": { "avatarUrl": null, "handle": "newuser", @@ -11,7 +11,7 @@ } }, { - "roles": "org_maintainer", + "role": "org_maintainer", "subject": { "avatarUrl": null, "handle": "test", @@ -20,7 +20,7 @@ } }, { - "roles": "org_owner", + "role": "org_owner", "subject": { "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", "handle": "transcripts", 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 6e6efdcc..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,7 +2,7 @@ "body": { "members": [ { - "roles": "org_maintainer", + "role": "org_maintainer", "subject": { "avatarUrl": null, "handle": "test", @@ -11,7 +11,7 @@ } }, { - "roles": "org_owner", + "role": "org_owner", "subject": { "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", "handle": "transcripts", diff --git a/transcripts/share-apis/orgs/org-get-members-public.json b/transcripts/share-apis/orgs/org-get-members-public.json index 6e6efdcc..778ecfaa 100644 --- a/transcripts/share-apis/orgs/org-get-members-public.json +++ b/transcripts/share-apis/orgs/org-get-members-public.json @@ -2,7 +2,7 @@ "body": { "members": [ { - "roles": "org_maintainer", + "role": "org_maintainer", "subject": { "avatarUrl": null, "handle": "test", @@ -11,7 +11,7 @@ } }, { - "roles": "org_owner", + "role": "org_owner", "subject": { "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", "handle": "transcripts", diff --git a/transcripts/share-apis/orgs/org-remove-members.json b/transcripts/share-apis/orgs/org-remove-members.json index 6e6efdcc..778ecfaa 100644 --- a/transcripts/share-apis/orgs/org-remove-members.json +++ b/transcripts/share-apis/orgs/org-remove-members.json @@ -2,7 +2,7 @@ "body": { "members": [ { - "roles": "org_maintainer", + "role": "org_maintainer", "subject": { "avatarUrl": null, "handle": "test", @@ -11,7 +11,7 @@ } }, { - "roles": "org_owner", + "role": "org_owner", "subject": { "avatarUrl": "https://www.gravatar.com/avatar/205e460b479e2e5b48aec07710c08d50?f=y&d=retro", "handle": "transcripts", diff --git a/transcripts/share-apis/orgs/run.zsh b/transcripts/share-apis/orgs/run.zsh index c4eb2296..8273f3a0 100755 --- a/transcripts/share-apis/orgs/run.zsh +++ b/transcripts/share-apis/orgs/run.zsh @@ -51,7 +51,7 @@ fetch "$admin_user" POST org-create-by-admin-commercial '/orgs' '{ fetch "$transcripts_user" POST org-add-members '/orgs/acme/members' '{ "members": [ { "subject": "test", - "roles": "org_maintainer" + "role": "org_maintainer" } ] }' @@ -68,7 +68,7 @@ fetch "$unauthorized_user" GET org-get-members-public '/orgs/acme/members' fetch "$unauthorized_user" POST org-add-members-unauthorized '/orgs/acme/members' '{ "members": [ { "subject": "unauthorized", - "roles": "org_maintainer" + "role": "org_maintainer" } ] }' @@ -76,7 +76,7 @@ fetch "$unauthorized_user" POST org-add-members-unauthorized '/orgs/acme/members fetch "$transcripts_user" POST org-add-members-new-user '/orgs/acme/members' '{ "members": [ { "subject": "newuser", - "roles": "org_maintainer" + "role": "org_maintainer" } ] }' @@ -85,7 +85,7 @@ fetch "$transcripts_user" POST org-add-members-new-user '/orgs/acme/members' '{ fetch "$transcripts_user" POST org-cant-have-org-members '/orgs/acme/members' '{ "members": [ { "subject": "unison", - "roles": "org_maintainer" + "role": "org_maintainer" } ] }'