fix(permissions): permissions checking for member and quota info (#9490)

1. Only show project member info when has member list permission.
2. Only show quota info when has quota read permission.
3. Add quota read permission for all roles of project.
4. Refactor permission service in portoal.
5. Clear cache when clear session.

Closes #8697

Signed-off-by: He Weiwei <hweiwei@vmware.com>
This commit is contained in:
He Weiwei 2019-10-21 14:03:52 +08:00 committed by GitHub
parent 91b7594442
commit e254fe3095
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 218 additions and 49 deletions

View File

@ -43,6 +43,7 @@ const (
ResourceLog = Resource("log")
ResourceMember = Resource("member")
ResourceMetadata = Resource("metadata")
ResourceQuota = Resource("quota")
ResourceReplication = Resource("replication") // TODO remove
ResourceReplicationJob = Resource("replication-job") // TODO remove
ResourceReplicationExecution = Resource("replication-execution")

View File

@ -53,6 +53,8 @@ var (
{Resource: rbac.ResourceLabelResource, Action: rbac.ActionList},
{Resource: rbac.ResourceQuota, Action: rbac.ActionRead},
{Resource: rbac.ResourceRepository, Action: rbac.ActionCreate},
{Resource: rbac.ResourceRepository, Action: rbac.ActionRead},
{Resource: rbac.ResourceRepository, Action: rbac.ActionUpdate},
@ -137,6 +139,8 @@ var (
{Resource: rbac.ResourceLog, Action: rbac.ActionList},
{Resource: rbac.ResourceQuota, Action: rbac.ActionRead},
{Resource: rbac.ResourceReplication, Action: rbac.ActionRead},
{Resource: rbac.ResourceReplication, Action: rbac.ActionList},
@ -220,6 +224,8 @@ var (
{Resource: rbac.ResourceLabel, Action: rbac.ActionRead},
{Resource: rbac.ResourceLabel, Action: rbac.ActionList},
{Resource: rbac.ResourceQuota, Action: rbac.ActionRead},
{Resource: rbac.ResourceRepository, Action: rbac.ActionCreate},
{Resource: rbac.ResourceRepository, Action: rbac.ActionRead},
{Resource: rbac.ResourceRepository, Action: rbac.ActionUpdate},
@ -273,6 +279,8 @@ var (
{Resource: rbac.ResourceLabel, Action: rbac.ActionRead},
{Resource: rbac.ResourceLabel, Action: rbac.ActionList},
{Resource: rbac.ResourceQuota, Action: rbac.ActionRead},
{Resource: rbac.ResourceRepository, Action: rbac.ActionRead},
{Resource: rbac.ResourceRepository, Action: rbac.ActionList},
{Resource: rbac.ResourceRepository, Action: rbac.ActionPull},
@ -303,6 +311,8 @@ var (
"limitedGuest": {
{Resource: rbac.ResourceSelf, Action: rbac.ActionRead},
{Resource: rbac.ResourceQuota, Action: rbac.ActionRead},
{Resource: rbac.ResourceRepository, Action: rbac.ActionList},
{Resource: rbac.ResourceRepository, Action: rbac.ActionPull},

View File

@ -593,8 +593,18 @@ func (p *ProjectAPI) Summary() {
ChartCount: p.project.ChartCount,
}
var fetchSummaries []func(int64, *models.ProjectSummary)
if hasPerm, _ := p.HasProjectPermission(p.project.ProjectID, rbac.ActionRead, rbac.ResourceQuota); hasPerm {
fetchSummaries = append(fetchSummaries, getProjectQuotaSummary)
}
if hasPerm, _ := p.HasProjectPermission(p.project.ProjectID, rbac.ActionList, rbac.ResourceMember); hasPerm {
fetchSummaries = append(fetchSummaries, getProjectMemberSummary)
}
var wg sync.WaitGroup
for _, fn := range []func(int64, *models.ProjectSummary){getProjectQuotaSummary, getProjectMemberSummary} {
for _, fn := range fetchSummaries {
fn := fn
wg.Add(1)
@ -685,7 +695,6 @@ func getProjectMemberSummary(projectID int64, summary *models.ProjectSummary) {
{common.RoleMaster, &summary.MasterCount},
{common.RoleDeveloper, &summary.DeveloperCount},
{common.RoleGuest, &summary.GuestCount},
{common.RoleLimitedGuest, &summary.LimitedGuestCount},
} {
wg.Add(1)
go func(role int, count *int64) {

103
src/portal/lib/src/cache/index.ts vendored Normal file
View File

@ -0,0 +1,103 @@
// Copyright Project Harbor Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Observable, of } from "rxjs";
import { tap, publishReplay, refCount } from "rxjs/operators";
function hashCode(str: string): string {
let hash: number = 0;
let chr: number;
if (str.length === 0) {
return hash.toString(36);
}
for (let i = 0; i < str.length; i++) {
chr = str.charCodeAt(i);
/* tslint:disable:no-bitwise */
hash = ((hash << 5) - hash) + chr;
hash |= 0;
/* tslint:enable:no-bitwise */
}
return hash.toString(36);
}
interface IObservableCacheValue {
response: Observable<any>;
/**
* created time of the cache value
*/
created?: Date;
}
interface IObservableCacheConfig {
/**
* maxAge of cache in milliseconds
*/
maxAge?: number;
/**
* whether should use sliding expiration on caches
*/
slidingExpiration?: boolean;
}
const cache: Map<string, IObservableCacheValue> = new Map<string, IObservableCacheValue>();
export function CacheObservable(config: IObservableCacheConfig = {}) {
return function (target: any, methodName: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
const targetName = target.constructor.name;
(descriptor.value as any) = function (...args: Array<any>) {
const key = hashCode(`${targetName}:${methodName}:${JSON.stringify(args)}`);
let value = cache.get(key);
if (value && value.created) {
if (new Date().getTime() - new Date(value.created).getTime() > config.maxAge) {
cache[key] = null;
value = null;
} else if (config.slidingExpiration) {
value.created = new Date();
cache.set(key, value);
}
}
if (value) {
return of(value.response);
}
const response$ = (original.apply(this, args) as Observable<any>).pipe(
tap((response: Observable<any>) => {
cache.set(key, {
response: response,
created: config.maxAge ? new Date() : null
});
}),
publishReplay(1),
refCount()
);
return response$;
};
return descriptor;
};
}
export function FlushAll() {
cache.clear();
}

View File

@ -29,4 +29,5 @@ export * from "./repository-gridview/index";
export * from "./operation/index";
export * from "./_animations/index";
export * from "./cron-schedule/index";
export * from "./cache/index";

View File

@ -53,6 +53,12 @@ export const USERSTATICPERMISSION = {
"READ": "read",
}
},
"QUOTA": {
"KEY": "quota",
"VALUE": {
"READ": "read"
}
},
"REPOSITORY": {
'KEY': 'repository',
'VALUE': {

View File

@ -13,14 +13,16 @@
// limitations under the License.
import { Injectable } from '@angular/core';
import { Observable, throwError as observableThrowError } from "rxjs";
import { map, catchError, shareReplay } from "rxjs/operators";
import { UserPrivilegeServeItem } from './interface';
import { Observable, forkJoin, of, throwError as observableThrowError } from "rxjs";
import { map, tap, publishReplay, refCount } from "rxjs/operators";
import { HttpClient } from '@angular/common/http';
import { CacheObservable } from '../cache';
interface Permission {
resource: string;
action: string;
}
const CACHE_SIZE = 1;
/**
* Get System privilege about current backend server.
* @abstract
@ -35,8 +37,11 @@ export abstract class UserPermissionService {
*/
abstract getPermission(projectId, resource, action);
abstract clearPermissionCache();
abstract hasProjectPermission(projectId: any, permission: Permission): Observable<boolean>;
abstract hasProjectPermissions(projectId: any, permissions: Array<Permission>): Observable<Array<boolean>>;
}
// @dynamic
@Injectable()
export class UserPermissionDefaultService extends UserPermissionService {
constructor(
@ -44,36 +49,37 @@ export class UserPermissionDefaultService extends UserPermissionService {
) {
super();
}
private permissionCache: Observable<object>;
private projectId: number;
private getPermissionFromBackend(projectId): Observable<object> {
const userPermissionUrl = `/api/users/current/permissions?scope=/project/${projectId}&relative=true`;
return this.http.get(userPermissionUrl);
}
private processingPermissionResult(responsePermission, resource, action): boolean {
const permissionList = responsePermission as UserPrivilegeServeItem[];
for (const privilegeItem of permissionList) {
if (privilegeItem.resource === resource && privilegeItem.action === action) {
return true;
}
}
return false;
}
public getPermission(projectId, resource, action): Observable<boolean> {
if (!this.permissionCache || this.projectId !== +projectId) {
this.projectId = +projectId;
this.permissionCache = this.getPermissionFromBackend(projectId).pipe(
shareReplay(CACHE_SIZE));
}
return this.permissionCache.pipe(map(response => {
return this.processingPermissionResult(response, resource, action);
}))
.pipe(catchError(error => observableThrowError(error)
@CacheObservable({ maxAge: 1000 * 60 })
private getPermissions(scope: string, relative?: boolean): Observable<Array<Permission>> {
const url = `/api/users/current/permissions?scope=${scope}&relative=${relative ? 'true' : 'false'}`;
return this.http.get<Array<Permission>>(url);
}
private hasPermission(permission: Permission, scope: string, relative?: boolean): Observable<boolean> {
return this.getPermissions(scope, relative).pipe(map(
(permissions: Array<Permission>) => {
return permissions.some((p: Permission) => p.resource === permission.resource && p.action === permission.action);
}
));
}
private hasPermissions(permissions: Array<Permission>, scope: string, relative?: boolean): Observable<Array<boolean>> {
return forkJoin(permissions.map((permission) => this.hasPermission(permission, scope, relative)));
}
public hasProjectPermission(projectId: any, permission: Permission): Observable<boolean> {
return this.hasPermission(permission, `/project/${projectId}`, true);
}
public hasProjectPermissions(projectId: any, permissions: Array<Permission>): Observable<Array<boolean>> {
return this.hasPermissions(permissions, `/project/${projectId}`, true);
}
public getPermission(projectId: any, resource: string, action: string): Observable<boolean> {
return this.hasProjectPermission(projectId, { resource, action });
}
public clearPermissionCache() {
this.permissionCache = null;
this.projectId = null;
}
}

View File

@ -12,18 +12,17 @@
<li>{{summaryInformation?.chart_count}}</li>
</ul>
</div>
<div class="display-flex project-detail pt-1">
<div *ngIf="showProjectMemberInfo" class="display-flex project-detail pt-1">
<h5 class="mt-0">{{'SUMMARY.PROJECT_MEMBER' | translate}}</h5>
<ul class="list-unstyled">
<li>{{ summaryInformation?.project_admin_count }} {{'SUMMARY.ADMIN' | translate}}</li>
<li>{{ summaryInformation?.master_count }} {{'SUMMARY.MASTER' | translate}}</li>
<li>{{ summaryInformation?.developer_count }} {{'SUMMARY.DEVELOPER' | translate}}</li>
<li>{{ summaryInformation?.guest_count }} {{'SUMMARY.GUEST' | translate}}</li>
<li>{{ summaryInformation?.limited_guest_count }} {{'SUMMARY.LIMITED_GUEST' | translate}}</li>
</ul>
</div>
</div>
<div class="summary-right pt-1">
<div *ngIf="showQuotaInfo" class="summary-right pt-1">
<div class="display-flex project-detail">
<h5 class="mt-0">{{'SUMMARY.PROJECT_QUOTAS' | translate}}</h5>
<div class="ml-1">

View File

@ -2,12 +2,13 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ClarityModule } from '@clr/angular';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { ProjectService, ErrorHandler} from '@harbor/ui';
import { ProjectService, ErrorHandler, UserPermissionService } from '@harbor/ui';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { AppConfigService } from "../../app-config.service";
import { SummaryComponent } from './summary.component';
describe('SummaryComponent', () => {
let component: SummaryComponent;
let fixture: ComponentFixture<SummaryComponent>;
@ -18,6 +19,11 @@ describe('SummaryComponent', () => {
}
};
let fakeErrorHandler = null;
let fakeUserPermissionService = {
hasProjectPermissions: function() {
return of([true, true]);
}
};
beforeEach(async(() => {
TestBed.configureTestingModule({
@ -34,6 +40,7 @@ describe('SummaryComponent', () => {
{ provide: AppConfigService, useValue: fakeAppConfigService },
{ provide: ProjectService, useValue: fakeProjectService },
{ provide: ErrorHandler, useValue: fakeErrorHandler },
{ provide: UserPermissionService, useValue: fakeUserPermissionService },
{
provide: ActivatedRoute, useValue: {
paramMap: of({ get: (key) => 'value' }),

View File

@ -1,41 +1,70 @@
import { Component, OnInit } from '@angular/core';
import { ProjectService, clone, QuotaUnits, getSuitableUnit, ErrorHandler, GetIntegerAndUnit
, QUOTA_DANGER_COEFFICIENT, QUOTA_WARNING_COEFFICIENT } from '@harbor/ui';
import { Component, Input, OnInit } from '@angular/core';
import {
ProjectService,
clone,
QuotaUnits,
getSuitableUnit,
ErrorHandler,
GetIntegerAndUnit,
UserPermissionService,
USERSTATICPERMISSION,
QUOTA_DANGER_COEFFICIENT,
QUOTA_WARNING_COEFFICIENT
} from '@harbor/ui';
import { ActivatedRoute } from '@angular/router';
import { AppConfigService } from "../../app-config.service";
@Component({
selector: 'summary',
templateUrl: './summary.component.html',
styleUrls: ['./summary.component.scss']
})
export class SummaryComponent implements OnInit {
showProjectMemberInfo: boolean;
showQuotaInfo: boolean;
projectId: number;
summaryInformation: any;
quotaDangerCoefficient: number = QUOTA_DANGER_COEFFICIENT;
quotaWarningCoefficient: number = QUOTA_WARNING_COEFFICIENT;
constructor(
private projectService: ProjectService,
private userPermissionService: UserPermissionService,
private errorHandler: ErrorHandler,
private appConfigService: AppConfigService,
private route: ActivatedRoute
) { }
) { }
ngOnInit() {
this.projectId = this.route.snapshot.parent.params['id'];
const permissions = [
{ resource: USERSTATICPERMISSION.MEMBER.KEY, action: USERSTATICPERMISSION.MEMBER.VALUE.LIST },
{ resource: USERSTATICPERMISSION.QUOTA.KEY, action: USERSTATICPERMISSION.QUOTA.VALUE.READ },
];
this.userPermissionService.hasProjectPermissions(this.projectId, permissions).subscribe((results: Array<boolean>) => {
this.showProjectMemberInfo = results[0];
this.showQuotaInfo = results[1];
});
this.projectService.getProjectSummary(this.projectId).subscribe(res => {
this.summaryInformation = res;
}, error => {
this.errorHandler.error(error);
});
}
getSuitableUnit(value) {
const QuotaUnitsCopy = clone(QuotaUnits);
return getSuitableUnit(value, QuotaUnitsCopy);
}
getIntegerAndUnit(hardValue, usedValue) {
return GetIntegerAndUnit(hardValue, clone(QuotaUnits), usedValue, clone(QuotaUnits));
}
public get withHelmChart(): boolean {
return this.appConfigService.getConfig().with_chartmuseum;
}

View File

@ -13,7 +13,7 @@
// limitations under the License.
import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { ErrorHandler, UserPermissionService, httpStatusCode, errorHandler } from '@harbor/ui';
import { ErrorHandler, httpStatusCode, errorHandler } from '@harbor/ui';
import { AlertType } from '../../shared/shared.const';
import { MessageService } from '../../global-message/message.service';
@ -26,7 +26,6 @@ export class MessageHandlerService implements ErrorHandler {
constructor(
private msgService: MessageService,
private translate: TranslateService,
private userPermissionService: UserPermissionService,
private session: SessionService) { }
// Handle the error and map it to the suitable message
@ -46,7 +45,6 @@ export class MessageHandlerService implements ErrorHandler {
this.msgService.announceAppLevelMessage(code, msg, AlertType.DANGER);
// Session is invalid now, clare session cache
this.session.clear();
this.userPermissionService.clearPermissionCache();
} else {
this.msgService.announceMessage(code, msg, AlertType.DANGER);
}

View File

@ -19,12 +19,12 @@ import {
CanActivateChild
} from '@angular/router';
import { SessionService } from '../../shared/session.service';
import { CommonRoutes, UserPermissionService } from '@harbor/ui';
import { CommonRoutes } from '@harbor/ui';
import { Observable } from 'rxjs';
@Injectable()
export class SignInGuard implements CanActivate, CanActivateChild {
constructor(private authService: SessionService, private router: Router, private userPermission: UserPermissionService) { }
constructor(private authService: SessionService, private router: Router) { }
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | boolean {
// If user has logged in, should not login again
@ -35,7 +35,6 @@ export class SignInGuard implements CanActivate, CanActivateChild {
this.authService.signOff()
.subscribe(() => {
this.authService.clear(); // Destroy session cache
this.userPermission.clearPermissionCache();
return observer.next(true);
}, error => {

View File

@ -21,7 +21,7 @@ import { Member } from '../project/member/member';
import { SignInCredential } from './sign-in-credential';
import { enLang } from '../shared/shared.const';
import { HTTP_FORM_OPTIONS, HTTP_JSON_OPTIONS, HTTP_GET_OPTIONS } from "@harbor/ui";
import { HTTP_FORM_OPTIONS, HTTP_JSON_OPTIONS, HTTP_GET_OPTIONS, FlushAll } from "@harbor/ui";
const signInUrl = '/c/login';
const currentUserEndpoint = "/api/users/current";
@ -62,6 +62,7 @@ export class SessionService {
clear(): void {
this.currentUser = null;
this.projectMembers = [];
FlushAll();
}
// Submit signin form to backend (NOT restful service)