Browse Source

feat(roles): Access checks for Team and Project (#47757)

Depends on #47698 and #47448

With team-level roles, an "org-member" can be a "team-admin". This will
grant them additional access scopes for specific teams and projects
(i.e. I can have `project:admin` for a few projects, but not all the
projects in my org)
Danny Lee 1 year ago
parent
commit
95728cb7b7

+ 1 - 0
fixtures/js-stubs/project.js

@@ -3,6 +3,7 @@ export function Project(params = {}) {
     id: '2',
     slug: 'project-slug',
     name: 'Project Name',
+    access: [],
     hasAccess: true,
     isMember: true,
     isBookmarked: false,

+ 3 - 1
fixtures/js-stubs/team.js

@@ -3,7 +3,9 @@ export function Team(params = {}) {
     id: '1',
     slug: 'team-slug',
     name: 'Team Name',
-    orgRole: null,
+    access: [],
+    orgRole: null, // TODO(cathy): Rename this
+    teamRole: null,
     isMember: true,
     memberCount: 0,
     flags: {

+ 82 - 10
static/app/components/acl/access.spec.jsx

@@ -40,7 +40,79 @@ describe('Access', function () {
       });
     });
 
-    it('handles no org/project', function () {
+    it('read access from team', function () {
+      const org = TestStubs.Organization({access: []});
+      const nextRouterContext = TestStubs.routerContext([{organization: org}]);
+
+      const team1 = TestStubs.Team({access: []});
+      render(
+        <Access access={['team:admin']} team={team1}>
+          {childrenMock}
+        </Access>,
+        {context: nextRouterContext, organization: org}
+      );
+
+      expect(childrenMock).toHaveBeenCalledWith(
+        expect.objectContaining({
+          hasAccess: false,
+          hasSuperuser: false,
+        })
+      );
+
+      const team2 = TestStubs.Team({
+        access: ['team:read', 'team:write', 'team:admin'],
+      });
+      render(
+        <Access access={['team:admin']} team={team2}>
+          {childrenMock}
+        </Access>,
+        {context: nextRouterContext, organization: org}
+      );
+
+      expect(childrenMock).toHaveBeenCalledWith(
+        expect.objectContaining({
+          hasAccess: true,
+          hasSuperuser: false,
+        })
+      );
+    });
+
+    it('read access from project', function () {
+      const org = TestStubs.Organization({access: []});
+      const nextRouterContext = TestStubs.routerContext([{organization: org}]);
+
+      const proj1 = TestStubs.Project({access: []});
+      render(
+        <Access access={['project:read']} project={proj1}>
+          {childrenMock}
+        </Access>,
+        {context: nextRouterContext, organization: org}
+      );
+
+      expect(childrenMock).toHaveBeenCalledWith(
+        expect.objectContaining({
+          hasAccess: false,
+          hasSuperuser: false,
+        })
+      );
+
+      const proj2 = TestStubs.Project({access: ['project:read']});
+      render(
+        <Access access={['project:read']} project={proj2}>
+          {childrenMock}
+        </Access>,
+        {context: nextRouterContext, organization: org}
+      );
+
+      expect(childrenMock).toHaveBeenCalledWith(
+        expect.objectContaining({
+          hasAccess: true,
+          hasSuperuser: false,
+        })
+      );
+    });
+
+    it('handles no org', function () {
       render(<Access access={['org:write']}>{childrenMock}</Access>, {
         context: routerContext,
         organization,
@@ -111,29 +183,29 @@ describe('Access', function () {
       expect(screen.getByText('The Child')).toBeInTheDocument();
     });
 
-    it('has superuser', function () {
-      ConfigStore.config = {
-        user: {isSuperuser: true},
-      };
+    it('has no access', function () {
       render(
-        <Access isSuperuser>
+        <Access access={['org:write']}>
           <p>The Child</p>
         </Access>,
         {context: routerContext, organization}
       );
 
-      expect(screen.getByText('The Child')).toBeInTheDocument();
+      expect(screen.queryByText('The Child')).not.toBeInTheDocument();
     });
 
-    it('has no access', function () {
+    it('has superuser', function () {
+      ConfigStore.config = {
+        user: {isSuperuser: true},
+      };
       render(
-        <Access access={['org:write']}>
+        <Access isSuperuser>
           <p>The Child</p>
         </Access>,
         {context: routerContext, organization}
       );
 
-      expect(screen.queryByText('The Child')).not.toBeInTheDocument();
+      expect(screen.getByText('The Child')).toBeInTheDocument();
     });
 
     it('has no superuser', function () {

+ 45 - 4
static/app/components/acl/access.tsx

@@ -2,7 +2,7 @@ import {Fragment} from 'react';
 
 import ConfigStore from 'sentry/stores/configStore';
 import {useLegacyStore} from 'sentry/stores/useLegacyStore';
-import {Scope} from 'sentry/types';
+import {Organization, Project, Scope, Team} from 'sentry/types';
 import {isRenderFunc} from 'sentry/utils/isRenderFunc';
 import useOrganization from 'sentry/utils/useOrganization';
 
@@ -14,7 +14,7 @@ type ChildRenderProps = {
 
 type ChildFunction = (props: ChildRenderProps) => JSX.Element;
 
-type Props = {
+export type Props = {
   /**
    * List of required access levels
    */
@@ -27,18 +27,42 @@ type Props = {
    * Requires superuser
    */
   isSuperuser?: boolean;
+
+  /**
+   * Optional: To be used when you need to check for access to the Project
+   *
+   * E.g. On the project settings page, the user will need project:write.
+   * An "org-member" does not have project:write but if they are "team-admin" for
+   * of a parent team, they will have appropriate scopes.
+   */
+  project?: Project | null | undefined;
+
+  /**
+   * Optional: To be used when you need to check for access to the Team
+   *
+   * E.g. On the team settings page, the user will need team:write.
+   * An "org-member" does not have team:write but if they are "team-admin" for
+   * the team, they will have appropriate scopes.
+   */
+  team?: Team | null | undefined;
 };
 
 /**
  * Component to handle access restrictions.
  */
-function Access({children, isSuperuser = false, access = []}: Props) {
+function Access({children, isSuperuser = false, access = [], team, project}: Props) {
   const config = useLegacyStore(ConfigStore);
   const organization = useOrganization();
 
   const {access: orgAccess} = organization || {access: []};
+  const {access: teamAccess} = team || {access: [] as Team['access']};
+  const {access: projAccess} = project || {access: [] as Project['access']};
 
-  const hasAccess = !access || access.every(acc => orgAccess.includes(acc));
+  const hasAccess =
+    !access ||
+    access.every(acc => orgAccess.includes(acc)) ||
+    access.every(acc => teamAccess?.includes(acc)) ||
+    access.every(acc => projAccess?.includes(acc));
   const hasSuperuser = !!(config.user && config.user.isSuperuser);
 
   const renderProps: ChildRenderProps = {
@@ -55,4 +79,21 @@ function Access({children, isSuperuser = false, access = []}: Props) {
   return <Fragment>{render ? children : null}</Fragment>;
 }
 
+export function hasEveryAccess(
+  access: Scope[],
+  props: {organization?: Organization; project?: Project; team?: Team}
+) {
+  const {organization, team, project} = props;
+  const {access: orgAccess} = organization || {access: [] as Organization['access']};
+  const {access: teamAccess} = team || {access: [] as Team['access']};
+  const {access: projAccess} = project || {access: [] as Project['access']};
+
+  return (
+    !access ||
+    access.every(acc => orgAccess.includes(acc)) ||
+    access.every(acc => teamAccess?.includes(acc)) ||
+    access.every(acc => projAccess?.includes(acc))
+  );
+}
+
 export default Access;

+ 3 - 2
static/app/types/organization.tsx

@@ -71,7 +71,8 @@ export interface Organization extends OrganizationSummary {
   orgRole?: string;
 }
 
-export type Team = {
+export interface Team {
+  access: Scope[];
   avatar: Avatar;
   externalTeams: ExternalTeam[];
   flags: {
@@ -86,7 +87,7 @@ export type Team = {
   orgRole: string | null;
   slug: string;
   teamRole: string | null;
-};
+}
 
 // TODO: Rename to BaseRole
 export interface MemberRole {

+ 2 - 1
static/app/types/project.tsx

@@ -1,6 +1,6 @@
 import type {PlatformKey} from 'sentry/data/platformCategories';
 
-import type {TimeseriesValue} from './core';
+import type {Scope, TimeseriesValue} from './core';
 import type {SDKUpdatesSuggestion} from './event';
 import type {Plugin} from './integrations';
 import type {Organization, Team} from './organization';
@@ -15,6 +15,7 @@ export type AvatarProject = {
 };
 
 export type Project = {
+  access: Scope[];
   dateCreated: string;
   digestsMaxDelay: number;
   digestsMinDelay: number;

+ 1 - 0
static/app/views/settings/projectSecurityAndPrivacy/index.tsx

@@ -36,6 +36,7 @@ export default function ProjectSecurityAndPrivacy({organization, project}: Props
     <Fragment>
       <SentryDocumentTitle title={title} projectSlug={projectSlug} />
       <SettingsPageHeader title={title} />
+
       <Form
         saveOnBlur
         allowUndo