diff --git a/src/common/rbac/const.go b/src/common/rbac/const.go index 6b850b6e9..0e96d4cd8 100755 --- a/src/common/rbac/const.go +++ b/src/common/rbac/const.go @@ -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") diff --git a/src/common/rbac/project/visitor_role.go b/src/common/rbac/project/visitor_role.go index 41ee7205e..e379357eb 100755 --- a/src/common/rbac/project/visitor_role.go +++ b/src/common/rbac/project/visitor_role.go @@ -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}, diff --git a/src/core/api/project.go b/src/core/api/project.go index 51bcb3d67..0028ccca7 100644 --- a/src/core/api/project.go +++ b/src/core/api/project.go @@ -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) { diff --git a/src/portal/lib/src/cache/index.ts b/src/portal/lib/src/cache/index.ts new file mode 100644 index 000000000..8a838c944 --- /dev/null +++ b/src/portal/lib/src/cache/index.ts @@ -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; + + /** + * 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 = new Map(); + +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) { + 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).pipe( + tap((response: Observable) => { + cache.set(key, { + response: response, + created: config.maxAge ? new Date() : null + }); + }), + publishReplay(1), + refCount() + ); + + return response$; + }; + + return descriptor; + }; +} + +export function FlushAll() { + cache.clear(); +} diff --git a/src/portal/lib/src/index.ts b/src/portal/lib/src/index.ts index d8728c3f8..4c893bfb9 100644 --- a/src/portal/lib/src/index.ts +++ b/src/portal/lib/src/index.ts @@ -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"; diff --git a/src/portal/lib/src/service/permission-static.ts b/src/portal/lib/src/service/permission-static.ts index 42518be03..07f3a31c5 100644 --- a/src/portal/lib/src/service/permission-static.ts +++ b/src/portal/lib/src/service/permission-static.ts @@ -53,6 +53,12 @@ export const USERSTATICPERMISSION = { "READ": "read", } }, + "QUOTA": { + "KEY": "quota", + "VALUE": { + "READ": "read" + } + }, "REPOSITORY": { 'KEY': 'repository', 'VALUE': { diff --git a/src/portal/lib/src/service/permission.service.ts b/src/portal/lib/src/service/permission.service.ts index e5d76d55d..3c31a43e3 100644 --- a/src/portal/lib/src/service/permission.service.ts +++ b/src/portal/lib/src/service/permission.service.ts @@ -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; + abstract hasProjectPermissions(projectId: any, permissions: Array): Observable>; } +// @dynamic @Injectable() export class UserPermissionDefaultService extends UserPermissionService { constructor( @@ -44,36 +49,37 @@ export class UserPermissionDefaultService extends UserPermissionService { ) { super(); } - private permissionCache: Observable; - private projectId: number; - private getPermissionFromBackend(projectId): Observable { - 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 { - 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> { + const url = `/api/users/current/permissions?scope=${scope}&relative=${relative ? 'true' : 'false'}`; + return this.http.get>(url); + } + + private hasPermission(permission: Permission, scope: string, relative?: boolean): Observable { + return this.getPermissions(scope, relative).pipe(map( + (permissions: Array) => { + return permissions.some((p: Permission) => p.resource === permission.resource && p.action === permission.action); + } )); } + + private hasPermissions(permissions: Array, scope: string, relative?: boolean): Observable> { + return forkJoin(permissions.map((permission) => this.hasPermission(permission, scope, relative))); + } + + public hasProjectPermission(projectId: any, permission: Permission): Observable { + return this.hasPermission(permission, `/project/${projectId}`, true); + } + + public hasProjectPermissions(projectId: any, permissions: Array): Observable> { + return this.hasPermissions(permissions, `/project/${projectId}`, true); + } + + public getPermission(projectId: any, resource: string, action: string): Observable { + return this.hasProjectPermission(projectId, { resource, action }); + } + public clearPermissionCache() { - this.permissionCache = null; - this.projectId = null; } } diff --git a/src/portal/src/app/project/summary/summary.component.html b/src/portal/src/app/project/summary/summary.component.html index be804c6ab..f673a1e58 100644 --- a/src/portal/src/app/project/summary/summary.component.html +++ b/src/portal/src/app/project/summary/summary.component.html @@ -12,18 +12,17 @@
  • {{summaryInformation?.chart_count}}
  • -
    +
    {{'SUMMARY.PROJECT_MEMBER' | translate}}
    • {{ summaryInformation?.project_admin_count }} {{'SUMMARY.ADMIN' | translate}}
    • {{ summaryInformation?.master_count }} {{'SUMMARY.MASTER' | translate}}
    • {{ summaryInformation?.developer_count }} {{'SUMMARY.DEVELOPER' | translate}}
    • {{ summaryInformation?.guest_count }} {{'SUMMARY.GUEST' | translate}}
    • -
    • {{ summaryInformation?.limited_guest_count }} {{'SUMMARY.LIMITED_GUEST' | translate}}
    -
    +
    {{'SUMMARY.PROJECT_QUOTAS' | translate}}
    diff --git a/src/portal/src/app/project/summary/summary.component.spec.ts b/src/portal/src/app/project/summary/summary.component.spec.ts index b5198992c..27d60f9ee 100644 --- a/src/portal/src/app/project/summary/summary.component.spec.ts +++ b/src/portal/src/app/project/summary/summary.component.spec.ts @@ -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; @@ -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' }), diff --git a/src/portal/src/app/project/summary/summary.component.ts b/src/portal/src/app/project/summary/summary.component.ts index 457d7fbb2..2d543d814 100644 --- a/src/portal/src/app/project/summary/summary.component.ts +++ b/src/portal/src/app/project/summary/summary.component.ts @@ -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) => { + 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; } diff --git a/src/portal/src/app/shared/message-handler/message-handler.service.ts b/src/portal/src/app/shared/message-handler/message-handler.service.ts index 8db13b912..ef6b83884 100644 --- a/src/portal/src/app/shared/message-handler/message-handler.service.ts +++ b/src/portal/src/app/shared/message-handler/message-handler.service.ts @@ -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); } diff --git a/src/portal/src/app/shared/route/sign-in-guard-activate.service.ts b/src/portal/src/app/shared/route/sign-in-guard-activate.service.ts index 5f748b379..b24aa3fa8 100644 --- a/src/portal/src/app/shared/route/sign-in-guard-activate.service.ts +++ b/src/portal/src/app/shared/route/sign-in-guard-activate.service.ts @@ -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 { // 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 => { diff --git a/src/portal/src/app/shared/session.service.ts b/src/portal/src/app/shared/session.service.ts index 6c1ce3dce..660f717a7 100644 --- a/src/portal/src/app/shared/session.service.ts +++ b/src/portal/src/app/shared/session.service.ts @@ -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)