[EC-416] Refactor organization permission checks (#3252)

* Replace Permissions enum and helper methods with callbacks

* Remove scim feature flag

* Check if org has feature enabled as part of canManage checks

* Pin jest-mock-extended at v2.0.6 to fix compilation error
This commit is contained in:
Thomas Rittson 2022-08-16 00:08:06 +10:00 committed by GitHub
parent 96d5f50c7f
commit d30701ada7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 474 additions and 282 deletions

View File

@ -16,7 +16,6 @@
"proxyEvents": "https://events.bitwarden.com"
},
"flags": {
"showTrial": false,
"scim": true
"showTrial": false
}
}

View File

@ -10,7 +10,6 @@
"proxyNotifications": "http://localhost:61840"
},
"flags": {
"showTrial": true,
"scim": true
"showTrial": true
}
}

View File

@ -10,7 +10,6 @@
"proxyEvents": "https://events.qa.bitwarden.pw"
},
"flags": {
"showTrial": true,
"scim": true
"showTrial": true
}
}

View File

@ -7,7 +7,6 @@
"port": 8081
},
"flags": {
"showTrial": false,
"scim": true
"showTrial": false
}
}

View File

@ -5,7 +5,7 @@ import { OrganizationService } from "@bitwarden/common/abstractions/organization
import { Utils } from "@bitwarden/common/misc/utils";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { NavigationPermissionsService } from "../organizations/services/navigation-permissions.service";
import { canAccessOrgAdmin } from "../organizations/navigation-permissions";
@Component({
selector: "app-organization-switcher",
@ -26,7 +26,7 @@ export class OrganizationSwitcherComponent implements OnInit {
async load() {
const orgs = await this.organizationService.getAll();
this.organizations = orgs
.filter((org) => NavigationPermissionsService.canAccessAdmin(org))
.filter(canAccessOrgAdmin)
.sort(Utils.getSortFunction(this.i18nService, "name"));
this.loaded = true;

View File

@ -12,7 +12,7 @@ import { Utils } from "@bitwarden/common/misc/utils";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { Provider } from "@bitwarden/common/models/domain/provider";
import { NavigationPermissionsService as OrgNavigationPermissionsService } from "../organizations/services/navigation-permissions.service";
import { canAccessOrgAdmin } from "../organizations/navigation-permissions";
@Component({
selector: "app-navbar",
@ -69,9 +69,7 @@ export class NavbarComponent implements OnInit {
async buildOrganizations() {
const allOrgs = await this.organizationService.getAll();
return allOrgs
.filter((org) => OrgNavigationPermissionsService.canAccessAdmin(org))
.sort(Utils.getSortFunction(this.i18nService, "name"));
return allOrgs.filter(canAccessOrgAdmin).sort(Utils.getSortFunction(this.i18nService, "name"));
}
lock() {

View File

@ -0,0 +1,163 @@
import {
ActivatedRouteSnapshot,
convertToParamMap,
Router,
RouterStateSnapshot,
} from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { SyncService } from "@bitwarden/common/abstractions/sync.service";
import { OrganizationUserType } from "@bitwarden/common/enums/organizationUserType";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { OrganizationPermissionsGuard } from "./org-permissions.guard";
const orgFactory = (props: Partial<Organization> = {}) =>
Object.assign(
new Organization(),
{
id: "myOrgId",
enabled: true,
type: OrganizationUserType.Admin,
},
props
);
describe("Organization Permissions Guard", () => {
let router: MockProxy<Router>;
let organizationService: MockProxy<OrganizationService>;
let state: MockProxy<RouterStateSnapshot>;
let route: MockProxy<ActivatedRouteSnapshot>;
let organizationPermissionsGuard: OrganizationPermissionsGuard;
beforeEach(() => {
router = mock<Router>();
organizationService = mock<OrganizationService>();
state = mock<RouterStateSnapshot>();
route = mock<ActivatedRouteSnapshot>({
params: {
organizationId: orgFactory().id,
},
data: {
organizationPermissions: null,
},
});
organizationPermissionsGuard = new OrganizationPermissionsGuard(
router,
organizationService,
mock<PlatformUtilsService>(),
mock<I18nService>(),
mock<SyncService>()
);
});
it("blocks navigation if organization does not exist", async () => {
organizationService.get.mockResolvedValue(null);
const actual = await organizationPermissionsGuard.canActivate(route, state);
expect(actual).not.toBe(true);
});
it("permits navigation if no permissions are specified", async () => {
const org = orgFactory();
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const actual = await organizationPermissionsGuard.canActivate(route, state);
expect(actual).toBe(true);
});
it("permits navigation if the user has permissions", async () => {
const permissionsCallback = jest.fn();
permissionsCallback.mockImplementation((org) => true);
route.data = {
organizationPermissions: permissionsCallback,
};
const org = orgFactory();
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const actual = await organizationPermissionsGuard.canActivate(route, state);
expect(permissionsCallback).toHaveBeenCalled();
expect(actual).toBe(true);
});
describe("if the user does not have permissions", () => {
it("and there is no Item ID, block navigation", async () => {
const permissionsCallback = jest.fn();
permissionsCallback.mockImplementation((org) => false);
route.data = {
organizationPermissions: permissionsCallback,
};
state = mock<RouterStateSnapshot>({
root: mock<ActivatedRouteSnapshot>({
queryParamMap: convertToParamMap({}),
}),
});
const org = orgFactory();
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const actual = await organizationPermissionsGuard.canActivate(route, state);
expect(permissionsCallback).toHaveBeenCalled();
expect(actual).not.toBe(true);
});
it("and there is an Item ID, redirect to the item in the individual vault", async () => {
route.data = {
organizationPermissions: (org: Organization) => false,
};
state = mock<RouterStateSnapshot>({
root: mock<ActivatedRouteSnapshot>({
queryParamMap: convertToParamMap({
itemId: "myItemId",
}),
}),
});
const org = orgFactory();
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const actual = await organizationPermissionsGuard.canActivate(route, state);
expect(router.createUrlTree).toHaveBeenCalledWith(["/vault"], {
queryParams: { itemId: "myItemId" },
});
expect(actual).not.toBe(true);
});
});
describe("given a disabled organization", () => {
it("blocks navigation if user is not an owner", async () => {
const org = orgFactory({
type: OrganizationUserType.Admin,
enabled: false,
});
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const actual = await organizationPermissionsGuard.canActivate(route, state);
expect(actual).not.toBe(true);
});
it("permits navigation if user is an owner", async () => {
const org = orgFactory({
type: OrganizationUserType.Owner,
enabled: false,
});
organizationService.get.calledWith(org.id).mockResolvedValue(org);
const actual = await organizationPermissionsGuard.canActivate(route, state);
expect(actual).toBe(true);
});
});
});

View File

@ -5,12 +5,14 @@ import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { SyncService } from "@bitwarden/common/abstractions/sync.service";
import { Permissions } from "@bitwarden/common/enums/permissions";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { canAccessOrgAdmin } from "../navigation-permissions";
@Injectable({
providedIn: "root",
})
export class PermissionsGuard implements CanActivate {
export class OrganizationPermissionsGuard implements CanActivate {
constructor(
private router: Router,
private organizationService: OrganizationService,
@ -39,8 +41,11 @@ export class PermissionsGuard implements CanActivate {
return this.router.createUrlTree(["/"]);
}
const permissions = route.data == null ? [] : (route.data.permissions as Permissions[]);
if (permissions != null && !org.hasAnyPermission(permissions)) {
const permissionsCallback: (organization: Organization) => boolean =
route.data?.organizationPermissions;
const hasPermissions = permissionsCallback == null || permissionsCallback(org);
if (!hasPermissions) {
// Handle linkable ciphers for organizations the user only has view access to
// https://bitwarden.atlassian.net/browse/EC-203
const cipherId =
@ -54,7 +59,9 @@ export class PermissionsGuard implements CanActivate {
}
this.platformUtilsService.showToast("error", null, this.i18nService.t("accessDenied"));
return this.router.createUrlTree(["/"]);
return canAccessOrgAdmin(org)
? this.router.createUrlTree(["/organizations", org.id])
: this.router.createUrlTree(["/"]);
}
return true;

View File

@ -5,7 +5,11 @@ import { BroadcasterService } from "@bitwarden/common/abstractions/broadcaster.s
import { OrganizationService } from "@bitwarden/common/abstractions/organization.service";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { NavigationPermissionsService } from "../services/navigation-permissions.service";
import {
canAccessManageTab,
canAccessSettingsTab,
canAccessToolsTab,
} from "../navigation-permissions";
const BroadcasterSubscriptionId = "OrganizationLayoutComponent";
@ -51,15 +55,15 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy {
}
get showManageTab(): boolean {
return NavigationPermissionsService.canAccessManage(this.organization);
return canAccessManageTab(this.organization);
}
get showToolsTab(): boolean {
return NavigationPermissionsService.canAccessTools(this.organization);
return canAccessToolsTab(this.organization);
}
get showSettingsTab(): boolean {
return NavigationPermissionsService.canAccessSettings(this.organization);
return canAccessSettingsTab(this.organization);
}
get toolsRoute(): string {

View File

@ -1,12 +1,11 @@
import { Component, OnInit, ViewChild, ViewContainerRef } from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { ActivatedRoute } from "@angular/router";
import { first } from "rxjs/operators";
import { ModalService } from "@bitwarden/angular/services/modal.service";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { Utils } from "@bitwarden/common/misc/utils";
@ -41,20 +40,13 @@ export class GroupsComponent implements OnInit {
private i18nService: I18nService,
private modalService: ModalService,
private platformUtilsService: PlatformUtilsService,
private router: Router,
private searchService: SearchService,
private logService: LogService,
private organizationService: OrganizationService
private logService: LogService
) {}
async ngOnInit() {
this.route.parent.parent.params.subscribe(async (params) => {
this.organizationId = params.organizationId;
const organization = await this.organizationService.get(this.organizationId);
if (organization == null || !organization.useGroups) {
this.router.navigate(["/organizations", this.organizationId]);
return;
}
await this.load();
this.route.queryParams.pipe(first()).subscribe(async (qParams) => {
this.searchText = qParams.search;

View File

@ -24,7 +24,7 @@
routerLink="groups"
class="list-group-item"
routerLinkActive="active"
*ngIf="organization.canManageGroups && accessGroups"
*ngIf="organization.canManageGroups"
>
{{ "groups" | i18n }}
</a>
@ -32,7 +32,7 @@
routerLink="policies"
class="list-group-item"
routerLinkActive="active"
*ngIf="organization.canManagePolicies && accessPolicies"
*ngIf="organization.canManagePolicies"
>
{{ "policies" | i18n }}
</a>
@ -40,7 +40,7 @@
routerLink="sso"
class="list-group-item"
routerLinkActive="active"
*ngIf="organization.canManageSso && accessSso"
*ngIf="organization.canManageSso"
>
{{ "singleSignOn" | i18n }}
</a>
@ -48,7 +48,7 @@
routerLink="scim"
class="list-group-item"
routerLinkActive="active"
*ngIf="organization.canManageScim && accessScim"
*ngIf="organization.canManageScim"
>
{{ "scim" | i18n }}
</a>
@ -56,7 +56,7 @@
routerLink="events"
class="list-group-item"
routerLinkActive="active"
*ngIf="organization.canAccessEventLogs && accessEvents"
*ngIf="organization.canAccessEventLogs"
>
{{ "eventLogs" | i18n }}
</a>

View File

@ -4,35 +4,18 @@ import { ActivatedRoute } from "@angular/router";
import { OrganizationService } from "@bitwarden/common/abstractions/organization.service";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { flagEnabled } from "../../../utils/flags";
@Component({
selector: "app-org-manage",
templateUrl: "manage.component.html",
})
export class ManageComponent implements OnInit {
organization: Organization;
accessPolicies = false;
accessGroups = false;
accessEvents = false;
accessSso = false;
accessScim = false;
constructor(private route: ActivatedRoute, private organizationService: OrganizationService) {}
ngOnInit() {
this.route.parent.params.subscribe(async (params) => {
this.organization = await this.organizationService.get(params.organizationId);
this.accessPolicies = this.organization.usePolicies;
this.accessSso = this.organization.useSso;
this.accessEvents = this.organization.useEvents;
this.accessGroups = this.organization.useGroups;
if (flagEnabled("scim")) {
this.accessScim = this.organization.useScim;
} else {
this.accessScim = false;
}
});
}
}

View File

@ -113,10 +113,6 @@ export class PeopleComponent
this.route.parent.parent.params.subscribe(async (params) => {
this.organizationId = params.organizationId;
const organization = await this.organizationService.get(this.organizationId);
if (!organization.canManageUsers) {
this.router.navigate(["../collections"], { relativeTo: this.route });
return;
}
this.accessEvents = organization.useEvents;
this.accessGroups = organization.useGroups;
this.canResetPassword = organization.canManageUsersPassword;

View File

@ -43,11 +43,6 @@ export class PoliciesComponent implements OnInit {
this.route.parent.parent.params.subscribe(async (params) => {
this.organizationId = params.organizationId;
this.organization = await this.organizationService.get(this.organizationId);
if (this.organization == null || !this.organization.usePolicies) {
this.router.navigate(["/organizations", this.organizationId]);
return;
}
this.policies = this.policyListService.getPolicies();
await this.load();

View File

@ -0,0 +1,29 @@
import { Organization } from "@bitwarden/common/models/domain/organization";
export function canAccessToolsTab(org: Organization): boolean {
return org.canAccessImportExport || org.canAccessReports;
}
export function canAccessSettingsTab(org: Organization): boolean {
return org.isOwner;
}
export function canAccessManageTab(org: Organization): boolean {
return (
org.canCreateNewCollections ||
org.canEditAnyCollection ||
org.canDeleteAnyCollection ||
org.canEditAssignedCollections ||
org.canDeleteAssignedCollections ||
org.canAccessEventLogs ||
org.canManageGroups ||
org.canManageUsers ||
org.canManagePolicies ||
org.canManageSso ||
org.canManageScim
);
}
export function canAccessOrgAdmin(org: Organization): boolean {
return canAccessToolsTab(org) || canAccessSettingsTab(org) || canAccessManageTab(org);
}

View File

@ -2,9 +2,9 @@ import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { AuthGuard } from "@bitwarden/angular/guards/auth.guard";
import { Permissions } from "@bitwarden/common/enums/permissions";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { PermissionsGuard } from "./guards/permissions.guard";
import { OrganizationPermissionsGuard } from "./guards/org-permissions.guard";
import { OrganizationLayoutComponent } from "./layouts/organization-layout.component";
import { CollectionsComponent } from "./manage/collections.component";
import { EventsComponent } from "./manage/events.component";
@ -12,7 +12,12 @@ import { GroupsComponent } from "./manage/groups.component";
import { ManageComponent } from "./manage/manage.component";
import { PeopleComponent } from "./manage/people.component";
import { PoliciesComponent } from "./manage/policies.component";
import { NavigationPermissionsService } from "./services/navigation-permissions.service";
import {
canAccessOrgAdmin,
canAccessManageTab,
canAccessSettingsTab,
canAccessToolsTab,
} from "./navigation-permissions";
import { AccountComponent } from "./settings/account.component";
import { OrganizationBillingComponent } from "./settings/organization-billing.component";
import { OrganizationSubscriptionComponent } from "./settings/organization-subscription.component";
@ -30,9 +35,9 @@ const routes: Routes = [
{
path: ":organizationId",
component: OrganizationLayoutComponent,
canActivate: [AuthGuard, PermissionsGuard],
canActivate: [AuthGuard, OrganizationPermissionsGuard],
data: {
permissions: NavigationPermissionsService.getPermissions("admin"),
organizationPermissions: canAccessOrgAdmin,
},
children: [
{ path: "", pathMatch: "full", redirectTo: "vault" },
@ -43,8 +48,10 @@ const routes: Routes = [
{
path: "tools",
component: ToolsComponent,
canActivate: [PermissionsGuard],
data: { permissions: NavigationPermissionsService.getPermissions("tools") },
canActivate: [OrganizationPermissionsGuard],
data: {
organizationPermissions: canAccessToolsTab,
},
children: [
{
path: "",
@ -61,46 +68,46 @@ const routes: Routes = [
{
path: "exposed-passwords-report",
component: ExposedPasswordsReportComponent,
canActivate: [PermissionsGuard],
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "exposedPasswordsReport",
permissions: [Permissions.AccessReports],
organizationPermissions: (org: Organization) => org.canAccessReports,
},
},
{
path: "inactive-two-factor-report",
component: InactiveTwoFactorReportComponent,
canActivate: [PermissionsGuard],
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "inactive2faReport",
permissions: [Permissions.AccessReports],
organizationPermissions: (org: Organization) => org.canAccessReports,
},
},
{
path: "reused-passwords-report",
component: ReusedPasswordsReportComponent,
canActivate: [PermissionsGuard],
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "reusedPasswordsReport",
permissions: [Permissions.AccessReports],
organizationPermissions: (org: Organization) => org.canAccessReports,
},
},
{
path: "unsecured-websites-report",
component: UnsecuredWebsitesReportComponent,
canActivate: [PermissionsGuard],
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "unsecuredWebsitesReport",
permissions: [Permissions.AccessReports],
organizationPermissions: (org: Organization) => org.canAccessReports,
},
},
{
path: "weak-passwords-report",
component: WeakPasswordsReportComponent,
canActivate: [PermissionsGuard],
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "weakPasswordsReport",
permissions: [Permissions.AccessReports],
organizationPermissions: (org: Organization) => org.canAccessReports,
},
},
],
@ -108,9 +115,9 @@ const routes: Routes = [
{
path: "manage",
component: ManageComponent,
canActivate: [PermissionsGuard],
canActivate: [OrganizationPermissionsGuard],
data: {
permissions: NavigationPermissionsService.getPermissions("manage"),
organizationPermissions: canAccessManageTab,
},
children: [
{
@ -121,52 +128,52 @@ const routes: Routes = [
{
path: "collections",
component: CollectionsComponent,
canActivate: [PermissionsGuard],
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "collections",
permissions: [
Permissions.CreateNewCollections,
Permissions.EditAnyCollection,
Permissions.DeleteAnyCollection,
Permissions.EditAssignedCollections,
Permissions.DeleteAssignedCollections,
],
organizationPermissions: (org: Organization) =>
org.canCreateNewCollections ||
org.canEditAnyCollection ||
org.canDeleteAnyCollection ||
org.canEditAssignedCollections ||
org.canDeleteAssignedCollections,
},
},
{
path: "events",
component: EventsComponent,
canActivate: [PermissionsGuard],
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "eventLogs",
permissions: [Permissions.AccessEventLogs],
organizationPermissions: (org: Organization) => org.canAccessEventLogs,
},
},
{
path: "groups",
component: GroupsComponent,
canActivate: [PermissionsGuard],
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "groups",
permissions: [Permissions.ManageGroups],
organizationPermissions: (org: Organization) => org.canManageGroups,
},
},
{
path: "people",
component: PeopleComponent,
canActivate: [PermissionsGuard],
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "people",
permissions: [Permissions.ManageUsers, Permissions.ManageUsersPassword],
organizationPermissions: (org: Organization) =>
org.canManageUsers || org.canManageUsersPassword,
},
},
{
path: "policies",
component: PoliciesComponent,
canActivate: [PermissionsGuard],
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "policies",
permissions: [Permissions.ManagePolicies],
organizationPermissions: (org: Organization) => org.canManagePolicies,
},
},
],
@ -174,8 +181,8 @@ const routes: Routes = [
{
path: "settings",
component: SettingsComponent,
canActivate: [PermissionsGuard],
data: { permissions: NavigationPermissionsService.getPermissions("settings") },
canActivate: [OrganizationPermissionsGuard],
data: { organizationPermissions: canAccessSettingsTab },
children: [
{ path: "", pathMatch: "full", redirectTo: "account" },
{ path: "account", component: AccountComponent, data: { titleId: "myOrganization" } },
@ -187,8 +194,11 @@ const routes: Routes = [
{
path: "billing",
component: OrganizationBillingComponent,
canActivate: [PermissionsGuard],
data: { titleId: "billing", permissions: [Permissions.ManageBilling] },
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "billing",
organizationPermissions: (org: Organization) => org.canManageBilling,
},
},
{
path: "subscription",

View File

@ -1,50 +0,0 @@
import { Permissions } from "@bitwarden/common/enums/permissions";
import { Organization } from "@bitwarden/common/models/domain/organization";
const permissions = {
manage: [
Permissions.CreateNewCollections,
Permissions.EditAnyCollection,
Permissions.DeleteAnyCollection,
Permissions.EditAssignedCollections,
Permissions.DeleteAssignedCollections,
Permissions.AccessEventLogs,
Permissions.ManageGroups,
Permissions.ManageUsers,
Permissions.ManagePolicies,
Permissions.ManageSso,
Permissions.ManageScim,
],
tools: [Permissions.AccessImportExport, Permissions.AccessReports],
settings: [Permissions.ManageOrganization],
};
export class NavigationPermissionsService {
static getPermissions(route: keyof typeof permissions | "admin") {
if (route === "admin") {
return Object.values(permissions).reduce((previous, current) => previous.concat(current), []);
}
return permissions[route];
}
static canAccessAdmin(organization: Organization): boolean {
return (
this.canAccessTools(organization) ||
this.canAccessSettings(organization) ||
this.canAccessManage(organization)
);
}
static canAccessTools(organization: Organization): boolean {
return organization.hasAnyPermission(NavigationPermissionsService.getPermissions("tools"));
}
static canAccessSettings(organization: Organization): boolean {
return organization.hasAnyPermission(NavigationPermissionsService.getPermissions("settings"));
}
static canAccessManage(organization: Organization): boolean {
return organization.hasAnyPermission(NavigationPermissionsService.getPermissions("manage"));
}
}

View File

@ -1,9 +1,9 @@
import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { Permissions } from "@bitwarden/common/enums/permissions";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { PermissionsGuard } from "../../guards/permissions.guard";
import { OrganizationPermissionsGuard } from "../../guards/org-permissions.guard";
import { OrganizationExportComponent } from "./org-export.component";
import { OrganizationImportComponent } from "./org-import.component";
@ -12,19 +12,19 @@ const routes: Routes = [
{
path: "import",
component: OrganizationImportComponent,
canActivate: [PermissionsGuard],
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "importData",
permissions: [Permissions.AccessImportExport],
organizationPermissions: (org: Organization) => org.canAccessImportExport,
},
},
{
path: "export",
component: OrganizationExportComponent,
canActivate: [PermissionsGuard],
canActivate: [OrganizationPermissionsGuard],
data: {
titleId: "exportVault",
permissions: [Permissions.AccessImportExport],
organizationPermissions: (org: Organization) => org.canAccessImportExport,
},
},
];

View File

@ -1,6 +1,5 @@
export type Flags = {
showTrial?: boolean;
scim?: boolean;
};
export type FlagName = keyof Flags;

View File

@ -0,0 +1,15 @@
const { pathsToModuleNameMapper } = require("ts-jest");
const { compilerOptions } = require("./tsconfig");
const sharedConfig = require("../../libs/shared/jest.config.base");
module.exports = {
...sharedConfig,
preset: "jest-preset-angular",
setupFilesAfterEnv: ["../../apps/web/test.setup.ts"],
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
prefix: "<rootDir>/",
}),
modulePathIgnorePatterns: ["jslib"],
};

View File

@ -2,12 +2,12 @@ import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { AuthGuard } from "@bitwarden/angular/guards/auth.guard";
import { Permissions } from "@bitwarden/common/enums/permissions";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { PermissionsGuard } from "src/app/organizations/guards/permissions.guard";
import { OrganizationPermissionsGuard } from "src/app/organizations/guards/org-permissions.guard";
import { OrganizationLayoutComponent } from "src/app/organizations/layouts/organization-layout.component";
import { ManageComponent } from "src/app/organizations/manage/manage.component";
import { NavigationPermissionsService } from "src/app/organizations/services/navigation-permissions.service";
import { canAccessManageTab } from "src/app/organizations/navigation-permissions";
import { ScimComponent } from "./manage/scim.component";
import { SsoComponent } from "./manage/sso.component";
@ -16,30 +16,30 @@ const routes: Routes = [
{
path: "organizations/:organizationId",
component: OrganizationLayoutComponent,
canActivate: [AuthGuard, PermissionsGuard],
canActivate: [AuthGuard, OrganizationPermissionsGuard],
children: [
{
path: "manage",
component: ManageComponent,
canActivate: [PermissionsGuard],
canActivate: [OrganizationPermissionsGuard],
data: {
permissions: NavigationPermissionsService.getPermissions("manage"),
organizationPermissions: canAccessManageTab,
},
children: [
{
path: "sso",
component: SsoComponent,
canActivate: [PermissionsGuard],
canActivate: [OrganizationPermissionsGuard],
data: {
permissions: [Permissions.ManageSso],
organizationPermissions: (org: Organization) => org.canManageSso,
},
},
{
path: "scim",
component: ScimComponent,
canActivate: [PermissionsGuard],
canActivate: [OrganizationPermissionsGuard],
data: {
permissions: [Permissions.ManageScim],
organizationPermissions: (org: Organization) => org.canManageScim,
},
},
],

View File

@ -0,0 +1,124 @@
import { ActivatedRouteSnapshot, Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { ProviderService } from "@bitwarden/common/abstractions/provider.service";
import { ProviderUserType } from "@bitwarden/common/enums/providerUserType";
import { Provider } from "@bitwarden/common/models/domain/provider";
import { ProviderPermissionsGuard } from "./provider-permissions.guard";
const providerFactory = (props: Partial<Provider> = {}) =>
Object.assign(
new Provider(),
{
id: "myProviderId",
enabled: true,
type: ProviderUserType.ServiceUser,
},
props
);
describe("Provider Permissions Guard", () => {
let router: MockProxy<Router>;
let providerService: MockProxy<ProviderService>;
let route: MockProxy<ActivatedRouteSnapshot>;
let providerPermissionsGuard: ProviderPermissionsGuard;
beforeEach(() => {
router = mock<Router>();
providerService = mock<ProviderService>();
route = mock<ActivatedRouteSnapshot>({
params: {
providerId: providerFactory().id,
},
data: {
providerPermissions: null,
},
});
providerPermissionsGuard = new ProviderPermissionsGuard(
providerService,
router,
mock<PlatformUtilsService>(),
mock<I18nService>()
);
});
it("blocks navigation if provider does not exist", async () => {
providerService.get.mockResolvedValue(null);
const actual = await providerPermissionsGuard.canActivate(route);
expect(actual).not.toBe(true);
});
it("permits navigation if no permissions are specified", async () => {
const provider = providerFactory();
providerService.get.calledWith(provider.id).mockResolvedValue(provider);
const actual = await providerPermissionsGuard.canActivate(route);
expect(actual).toBe(true);
});
it("permits navigation if the user has permissions", async () => {
const permissionsCallback = jest.fn();
permissionsCallback.mockImplementation((provider) => true);
route.data = {
providerPermissions: permissionsCallback,
};
const provider = providerFactory();
providerService.get.calledWith(provider.id).mockResolvedValue(provider);
const actual = await providerPermissionsGuard.canActivate(route);
expect(permissionsCallback).toHaveBeenCalled();
expect(actual).toBe(true);
});
it("blocks navigation if the user does not have permissions", async () => {
const permissionsCallback = jest.fn();
permissionsCallback.mockImplementation((org) => false);
route.data = {
providerPermissions: permissionsCallback,
};
const provider = providerFactory();
providerService.get.calledWith(provider.id).mockResolvedValue(provider);
const actual = await providerPermissionsGuard.canActivate(route);
expect(permissionsCallback).toHaveBeenCalled();
expect(actual).not.toBe(true);
});
describe("given a disabled organization", () => {
it("blocks navigation if user is not an admin", async () => {
const org = providerFactory({
type: ProviderUserType.ServiceUser,
enabled: false,
});
providerService.get.calledWith(org.id).mockResolvedValue(org);
const actual = await providerPermissionsGuard.canActivate(route);
expect(actual).not.toBe(true);
});
it("permits navigation if user is an admin", async () => {
const org = providerFactory({
type: ProviderUserType.ProviderAdmin,
enabled: false,
});
providerService.get.calledWith(org.id).mockResolvedValue(org);
const actual = await providerPermissionsGuard.canActivate(route);
expect(actual).toBe(true);
});
});
});

View File

@ -4,26 +4,34 @@ import { ActivatedRouteSnapshot, CanActivate, Router } from "@angular/router";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
import { ProviderService } from "@bitwarden/common/abstractions/provider.service";
import { Provider } from "@bitwarden/common/models/domain/provider";
@Injectable()
export class ProviderGuard implements CanActivate {
export class ProviderPermissionsGuard implements CanActivate {
constructor(
private providerService: ProviderService,
private router: Router,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private providerService: ProviderService
private i18nService: I18nService
) {}
async canActivate(route: ActivatedRouteSnapshot) {
const provider = await this.providerService.get(route.params.providerId);
if (provider == null) {
this.router.navigate(["/"]);
return false;
return this.router.createUrlTree(["/"]);
}
if (!provider.isProviderAdmin && !provider.enabled) {
this.platformUtilsService.showToast("error", null, this.i18nService.t("providerIsDisabled"));
this.router.navigate(["/"]);
return false;
return this.router.createUrlTree(["/"]);
}
const permissionsCallback: (provider: Provider) => boolean = route.data?.providerPermissions;
const hasSpecifiedPermissions = permissionsCallback == null || permissionsCallback(provider);
if (!hasSpecifiedPermissions) {
this.platformUtilsService.showToast("error", null, this.i18nService.t("accessDenied"));
return this.router.createUrlTree(["/providers", provider.id]);
}
return true;

View File

@ -1,26 +0,0 @@
import { Injectable } from "@angular/core";
import { ActivatedRouteSnapshot, CanActivate, Router } from "@angular/router";
import { ProviderService } from "@bitwarden/common/abstractions/provider.service";
import { Permissions } from "@bitwarden/common/enums/permissions";
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private providerService: ProviderService, private router: Router) {}
async canActivate(route: ActivatedRouteSnapshot) {
const provider = await this.providerService.get(route.params.providerId);
const permissions = route.data == null ? null : (route.data.permissions as Permissions[]);
if (
(permissions.indexOf(Permissions.AccessEventLogs) !== -1 && provider.canAccessEventLogs) ||
(permissions.indexOf(Permissions.ManageProvider) !== -1 && provider.isProviderAdmin) ||
(permissions.indexOf(Permissions.ManageUsers) !== -1 && provider.canManageUsers)
) {
return true;
}
this.router.navigate(["/providers", provider.id]);
return false;
}
}

View File

@ -2,15 +2,14 @@ import { NgModule } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import { AuthGuard } from "@bitwarden/angular/guards/auth.guard";
import { Permissions } from "@bitwarden/common/enums/permissions";
import { Provider } from "@bitwarden/common/models/domain/provider";
import { FrontendLayoutComponent } from "src/app/layouts/frontend-layout.component";
import { ProvidersComponent } from "src/app/providers/providers.component";
import { ClientsComponent } from "./clients/clients.component";
import { CreateOrganizationComponent } from "./clients/create-organization.component";
import { PermissionsGuard } from "./guards/provider-type.guard";
import { ProviderGuard } from "./guards/provider.guard";
import { ProviderPermissionsGuard } from "./guards/provider-permissions.guard";
import { AcceptProviderComponent } from "./manage/accept-provider.component";
import { EventsComponent } from "./manage/events.component";
import { ManageComponent } from "./manage/manage.component";
@ -54,7 +53,7 @@ const routes: Routes = [
{
path: ":providerId",
component: ProvidersLayoutComponent,
canActivate: [ProviderGuard],
canActivate: [ProviderPermissionsGuard],
children: [
{ path: "", pathMatch: "full", redirectTo: "clients" },
{ path: "clients/create", component: CreateOrganizationComponent },
@ -71,19 +70,19 @@ const routes: Routes = [
{
path: "people",
component: PeopleComponent,
canActivate: [PermissionsGuard],
canActivate: [ProviderPermissionsGuard],
data: {
titleId: "people",
permissions: [Permissions.ManageUsers],
providerPermissions: (provider: Provider) => provider.canManageUsers,
},
},
{
path: "events",
component: EventsComponent,
canActivate: [PermissionsGuard],
canActivate: [ProviderPermissionsGuard],
data: {
titleId: "eventLogs",
permissions: [Permissions.AccessEventLogs],
providerPermissions: (provider: Provider) => provider.canAccessEventLogs,
},
},
],
@ -100,10 +99,10 @@ const routes: Routes = [
{
path: "account",
component: AccountComponent,
canActivate: [PermissionsGuard],
canActivate: [ProviderPermissionsGuard],
data: {
titleId: "myProvider",
permissions: [Permissions.ManageProvider],
providerPermissions: (provider: Provider) => provider.isProviderAdmin,
},
},
],

View File

@ -10,8 +10,7 @@ import { OssModule } from "src/app/oss.module";
import { AddOrganizationComponent } from "./clients/add-organization.component";
import { ClientsComponent } from "./clients/clients.component";
import { CreateOrganizationComponent } from "./clients/create-organization.component";
import { PermissionsGuard } from "./guards/provider-type.guard";
import { ProviderGuard } from "./guards/provider.guard";
import { ProviderPermissionsGuard } from "./guards/provider-permissions.guard";
import { AcceptProviderComponent } from "./manage/accept-provider.component";
import { BulkConfirmComponent } from "./manage/bulk/bulk-confirm.component";
import { BulkRemoveComponent } from "./manage/bulk/bulk-remove.component";
@ -46,7 +45,7 @@ import { SetupComponent } from "./setup/setup.component";
SetupProviderComponent,
UserAddEditComponent,
],
providers: [WebProviderService, ProviderGuard, PermissionsGuard],
providers: [WebProviderService, ProviderPermissionsGuard],
})
export class ProvidersModule {
constructor(modalService: ModalService, componentFactoryResolver: ComponentFactoryResolver) {

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"files": ["../../apps/web/test.setup.ts"]
}

View File

@ -13,6 +13,7 @@ module.exports = {
"<rootDir>/apps/browser/jest.config.js",
"<rootDir>/apps/cli/jest.config.js",
"<rootDir>/apps/web/jest.config.js",
"<rootDir>/bitwarden_license/bit-web/jest.config.js",
"<rootDir>/libs/angular/jest.config.js",
"<rootDir>/libs/common/jest.config.js",

View File

@ -1,29 +0,0 @@
export enum Permissions {
AccessEventLogs,
AccessImportExport,
AccessReports,
/**
* @deprecated Sep 29 2021: This permission has been split out to `createNewCollections`, `editAnyCollection`, and
* `deleteAnyCollection`. It exists here for backwards compatibility with Server versions <= 1.43.0
*/
ManageAllCollections,
/**
* @deprecated Sep 29 2021: This permission has been split out to `editAssignedCollections` and
* `deleteAssignedCollections`. It exists here for backwards compatibility with Server versions <= 1.43.0
*/
ManageAssignedCollections,
ManageGroups,
ManageOrganization,
ManagePolicies,
ManageProvider,
ManageUsers,
ManageUsersPassword,
CreateNewCollections,
EditAnyCollection,
DeleteAnyCollection,
EditAssignedCollections,
DeleteAssignedCollections,
ManageSso,
ManageBilling,
ManageScim,
}

View File

@ -1,6 +1,5 @@
import { OrganizationUserStatusType } from "../../enums/organizationUserStatusType";
import { OrganizationUserType } from "../../enums/organizationUserType";
import { Permissions } from "../../enums/permissions";
import { ProductType } from "../../enums/productType";
import { PermissionsApi } from "../api/permissionsApi";
import { OrganizationData } from "../data/organizationData";
@ -114,7 +113,7 @@ export class Organization {
}
get canAccessEventLogs() {
return this.isAdmin || this.permissions.accessEventLogs;
return (this.isAdmin || this.permissions.accessEventLogs) && this.useEvents;
}
get canAccessImportExport() {
@ -168,11 +167,11 @@ export class Organization {
}
get canManageGroups() {
return this.isAdmin || this.permissions.manageGroups;
return (this.isAdmin || this.permissions.manageGroups) && this.useGroups;
}
get canManageSso() {
return this.isAdmin || this.permissions.manageSso;
return (this.isAdmin || this.permissions.manageSso) && this.useSso;
}
get canManageScim() {
@ -180,7 +179,7 @@ export class Organization {
}
get canManagePolicies() {
return this.isAdmin || this.permissions.managePolicies;
return (this.isAdmin || this.permissions.managePolicies) && this.usePolicies;
}
get canManageUsers() {
@ -195,30 +194,6 @@ export class Organization {
return this.canManagePolicies;
}
hasAnyPermission(permissions: Permissions[]) {
const specifiedPermissions =
(permissions.includes(Permissions.AccessEventLogs) && this.canAccessEventLogs) ||
(permissions.includes(Permissions.AccessImportExport) && this.canAccessImportExport) ||
(permissions.includes(Permissions.AccessReports) && this.canAccessReports) ||
(permissions.includes(Permissions.CreateNewCollections) && this.canCreateNewCollections) ||
(permissions.includes(Permissions.EditAnyCollection) && this.canEditAnyCollection) ||
(permissions.includes(Permissions.DeleteAnyCollection) && this.canDeleteAnyCollection) ||
(permissions.includes(Permissions.EditAssignedCollections) &&
this.canEditAssignedCollections) ||
(permissions.includes(Permissions.DeleteAssignedCollections) &&
this.canDeleteAssignedCollections) ||
(permissions.includes(Permissions.ManageGroups) && this.canManageGroups) ||
(permissions.includes(Permissions.ManageOrganization) && this.isOwner) ||
(permissions.includes(Permissions.ManagePolicies) && this.canManagePolicies) ||
(permissions.includes(Permissions.ManageUsers) && this.canManageUsers) ||
(permissions.includes(Permissions.ManageUsersPassword) && this.canManageUsersPassword) ||
(permissions.includes(Permissions.ManageSso) && this.canManageSso) ||
(permissions.includes(Permissions.ManageScim) && this.canManageScim) ||
(permissions.includes(Permissions.ManageBilling) && this.canManageBilling);
return specifiedPermissions && (this.enabled || this.isOwner);
}
get canManageBilling() {
return this.isOwner && (this.isProviderUser || !this.hasProvider);
}

14
package-lock.json generated
View File

@ -143,7 +143,7 @@
"husky": "^8.0.1",
"jasmine-core": "^3.7.1",
"jasmine-spec-reporter": "^7.0.0",
"jest-mock-extended": "^2.0.6",
"jest-mock-extended": "2.0.6",
"jest-preset-angular": "^12.1.0",
"lint-staged": "^13.0.3",
"mini-css-extract-plugin": "^2.4.5",
@ -28286,9 +28286,9 @@
}
},
"node_modules/jest-mock-extended": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-2.0.7.tgz",
"integrity": "sha512-h8brJJN5BZb03hTwplvt+raT6Nj0U2U71Z26Py12Qc3kvYnAjDW/zSuQJLnXCNyyufy592VC9k3X7AOz+2H52g==",
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-2.0.6.tgz",
"integrity": "sha512-KoDdjqwIp2phaOWB0hr4O+9HF7hIJx7O+Reefi3iGrNhUpzVkod9UozYTSanvbNvjFYIEH6noA2tIjc8IDpadw==",
"dev": true,
"dependencies": {
"ts-essentials": "^7.0.3"
@ -64464,9 +64464,9 @@
}
},
"jest-mock-extended": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-2.0.7.tgz",
"integrity": "sha512-h8brJJN5BZb03hTwplvt+raT6Nj0U2U71Z26Py12Qc3kvYnAjDW/zSuQJLnXCNyyufy592VC9k3X7AOz+2H52g==",
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-2.0.6.tgz",
"integrity": "sha512-KoDdjqwIp2phaOWB0hr4O+9HF7hIJx7O+Reefi3iGrNhUpzVkod9UozYTSanvbNvjFYIEH6noA2tIjc8IDpadw==",
"dev": true,
"requires": {
"ts-essentials": "^7.0.3"

View File

@ -106,7 +106,7 @@
"husky": "^8.0.1",
"jasmine-core": "^3.7.1",
"jasmine-spec-reporter": "^7.0.0",
"jest-mock-extended": "^2.0.6",
"jest-mock-extended": "2.0.6",
"jest-preset-angular": "^12.1.0",
"lint-staged": "^13.0.3",
"mini-css-extract-plugin": "^2.4.5",