diff --git a/deploy/kubernetes/console/README.md b/deploy/kubernetes/console/README.md index ad042ec99f..7cf0bf36ff 100644 --- a/deploy/kubernetes/console/README.md +++ b/deploy/kubernetes/console/README.md @@ -78,6 +78,7 @@ The following table lists the configurable parameters of the Stratos Helm chart |console.userInviteSubject|Email subject of the user invitation message|| |console.techPreview|Enable/disable Tech Preview features|false| |console.apiKeysEnabled|Enable/disable API key-based access to Stratos API (disabled, admin_only, all_users)|admin_only| +|console.userEndpointsEnabled|Enable/disable user endpoints or let only admins view and manage user endpoints (disabled, admin_only, enabled)|disabled| |console.ui.listMaxSize|Override the default maximum number of entities that a configured list can fetch. When a list meets this amount additional pages are not fetched|| |console.ui.listAllowLoadMaxed|If the maximum list size is met give the user the option to fetch all results|false| |console.localAdminPassword|Use local admin user instead of UAA - set to a password to enable|| diff --git a/deploy/kubernetes/console/templates/deployment.yaml b/deploy/kubernetes/console/templates/deployment.yaml index 3faa48f2b6..485c084ae5 100644 --- a/deploy/kubernetes/console/templates/deployment.yaml +++ b/deploy/kubernetes/console/templates/deployment.yaml @@ -288,6 +288,8 @@ spec: value: {{ default "false" .Values.console.techPreview | quote }} - name: API_KEYS_ENABLED value: {{ default "admin_only" .Values.console.apiKeysEnabled | quote }} + - name: USER_ENDPOINTS_ENABLED + value: {{ default "disabled" .Values.console.userEndpointsEnabled | quote }} - name: HELM_CACHE_FOLDER value: /helm-cache {{- if .Values.console.ui }} diff --git a/deploy/kubernetes/console/values.schema.json b/deploy/kubernetes/console/values.schema.json index 17c7ddeb6d..9ff9cfeb90 100644 --- a/deploy/kubernetes/console/values.schema.json +++ b/deploy/kubernetes/console/values.schema.json @@ -14,6 +14,11 @@ "enum": ["disabled", "admin_only", "all_users"], "description": "Enable API keys for admins, all users or nobody" }, + "userEndpointsEnabled": { + "type": "string", + "enum": ["disabled", "admin_only", "enabled"], + "description": "Enable, disable or let only admins view and create user endpoints" + }, "autoRegisterCF": { "type": ["string", "null"] }, diff --git a/deploy/kubernetes/console/values.yaml b/deploy/kubernetes/console/values.yaml index 5c7b537ede..a3260da2b0 100644 --- a/deploy/kubernetes/console/values.yaml +++ b/deploy/kubernetes/console/values.yaml @@ -70,6 +70,9 @@ console: # Enable/disable API key-based access to Stratos API apiKeysEnabled: admin_only + # Enable/disable user endpoints + userEndpointsEnabled: disabled + ui: # Override the default maximum number of entities that a configured list can fetch. When a list meets this amount additional pages are not fetched listMaxSize: diff --git a/src/frontend/packages/cloud-foundry/src/shared/services/current-user-permissions-and-cfchecker.service.spec.ts b/src/frontend/packages/cloud-foundry/src/shared/services/current-user-permissions-and-cfchecker.service.spec.ts index 0852f7e8b2..c41db83388 100644 --- a/src/frontend/packages/cloud-foundry/src/shared/services/current-user-permissions-and-cfchecker.service.spec.ts +++ b/src/frontend/packages/cloud-foundry/src/shared/services/current-user-permissions-and-cfchecker.service.spec.ts @@ -64,6 +64,11 @@ describe('CurrentUserPermissionsService with CF checker', () => { CfScopeStrings.CF_READ_SCOPE, ] }, + creator: { + name: 'admin', + admin: true, + system: false + }, metricsAvailable: false, connectionStatus: 'connected', system_shared_token: false, @@ -102,6 +107,11 @@ describe('CurrentUserPermissionsService with CF checker', () => { StratosScopeStrings.SCIM_READ ] }, + creator: { + name: 'admin', + admin: true, + system: false + }, metricsAvailable: false, connectionStatus: 'connected', system_shared_token: false, diff --git a/src/frontend/packages/core/src/core/endpoints.service.spec.ts b/src/frontend/packages/core/src/core/endpoints.service.spec.ts index 3ac576abcd..8788c3d682 100644 --- a/src/frontend/packages/core/src/core/endpoints.service.spec.ts +++ b/src/frontend/packages/core/src/core/endpoints.service.spec.ts @@ -3,6 +3,7 @@ import { createBasicStoreModule } from '@stratosui/store/testing'; import { PaginationMonitorFactory } from '../../../store/src/monitors/pagination-monitor.factory'; import { CoreTestingModule } from '../../test-framework/core-test.modules'; +import { SessionService } from '../shared/services/session.service'; import { CoreModule } from './core.module'; import { EndpointsService } from './endpoints.service'; import { UtilsService } from './utils.service'; @@ -13,7 +14,8 @@ describe('EndpointsService', () => { providers: [ EndpointsService, UtilsService, - PaginationMonitorFactory + PaginationMonitorFactory, + SessionService ], imports: [ CoreModule, diff --git a/src/frontend/packages/core/src/core/endpoints.service.ts b/src/frontend/packages/core/src/core/endpoints.service.ts index f7860ba3c3..08559e32fb 100644 --- a/src/frontend/packages/core/src/core/endpoints.service.ts +++ b/src/frontend/packages/core/src/core/endpoints.service.ts @@ -14,6 +14,7 @@ import { endpointEntitiesSelector, endpointStatusSelector } from '../../../store import { EndpointModel, EndpointState } from '../../../store/src/types/endpoint.types'; import { IEndpointFavMetadata, UserFavorite } from '../../../store/src/types/user-favorites.types'; import { endpointHasMetricsByAvailable } from '../features/endpoints/endpoint-helpers'; +import { SessionService } from '../shared/services/session.service'; import { EndpointHealthChecks } from './endpoints-health-checks'; import { UserService } from './user.service'; @@ -50,7 +51,8 @@ export class EndpointsService implements CanActivate { constructor( private store: Store, private userService: UserService, - private endpointHealthChecks: EndpointHealthChecks + private endpointHealthChecks: EndpointHealthChecks, + private sessionService: SessionService ) { this.endpoints$ = store.select(endpointEntitiesSelector); this.haveRegistered$ = this.endpoints$.pipe(map(endpoints => !!Object.keys(endpoints).length)); @@ -99,17 +101,19 @@ export class EndpointsService implements CanActivate { this.haveRegistered$, this.haveConnected$, this.userService.isAdmin$, + this.userService.isEndpointAdmin$, + this.sessionService.userEndpointsEnabled(), this.disablePersistenceFeatures$ ), - map(([state, haveRegistered, haveConnected, isAdmin, disablePersistenceFeatures] - : [[AuthState, EndpointState], boolean, boolean, boolean, boolean]) => { + map(([state, haveRegistered, haveConnected, isAdmin, isEndpointAdmin, userEndpointsEnabled, disablePersistenceFeatures] + : [[AuthState, EndpointState], boolean, boolean, boolean, boolean, boolean, boolean]) => { const [authState] = state; if (authState.sessionData.valid) { // Redirect to endpoints if there's no connected endpoints let redirect: string; if (!disablePersistenceFeatures) { if (!haveRegistered) { - redirect = isAdmin ? '/endpoints' : '/noendpoints'; + redirect = isAdmin || (userEndpointsEnabled && isEndpointAdmin) ? '/endpoints' : '/noendpoints'; } else if (!haveConnected) { redirect = '/endpoints'; } diff --git a/src/frontend/packages/core/src/core/entity-favorite-star/entity-favorite-star.component.spec.ts b/src/frontend/packages/core/src/core/entity-favorite-star/entity-favorite-star.component.spec.ts index 5b0c078512..755a43f5e2 100644 --- a/src/frontend/packages/core/src/core/entity-favorite-star/entity-favorite-star.component.spec.ts +++ b/src/frontend/packages/core/src/core/entity-favorite-star/entity-favorite-star.component.spec.ts @@ -8,6 +8,7 @@ import { UserFavoriteManager } from '../../../../store/src/user-favorite-manager import { BaseTestModulesNoShared } from '../../../test-framework/core-test.helper'; import { ConfirmationDialogService } from '../../shared/components/confirmation-dialog.service'; import { DialogConfirmComponent } from '../../shared/components/dialog-confirm/dialog-confirm.component'; +import { SessionService } from '../../shared/services/session.service'; import { EntityFavoriteStarComponent } from './entity-favorite-star.component'; describe('EntityFavoriteStarComponent', () => { @@ -28,7 +29,8 @@ describe('EntityFavoriteStarComponent', () => { overlayContainerElement = document.createElement('div'); return { getContainerElement: () => overlayContainerElement }; } - } + }, + SessionService ], declarations: [ DialogConfirmComponent diff --git a/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.spec.ts b/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.spec.ts index 9d7f3bb0af..d6c2ee25f4 100644 --- a/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.spec.ts +++ b/src/frontend/packages/core/src/core/permissions/current-user-permissions.service.spec.ts @@ -50,6 +50,11 @@ describe('CurrentUserPermissionsService', () => { StratosScopeStrings.STRATOS_CHANGE_PASSWORD, ] }, + creator: { + name: 'admin', + admin: true, + system: false + }, metricsAvailable: false, connectionStatus: 'connected', system_shared_token: false, @@ -83,6 +88,11 @@ describe('CurrentUserPermissionsService', () => { StratosScopeStrings.SCIM_READ ] }, + creator: { + name: 'admin', + admin: true, + system: false + }, metricsAvailable: false, connectionStatus: 'connected', system_shared_token: false, diff --git a/src/frontend/packages/core/src/core/permissions/stratos-user-permissions.checker.ts b/src/frontend/packages/core/src/core/permissions/stratos-user-permissions.checker.ts index e3410a2a1f..d44a58635a 100644 --- a/src/frontend/packages/core/src/core/permissions/stratos-user-permissions.checker.ts +++ b/src/frontend/packages/core/src/core/permissions/stratos-user-permissions.checker.ts @@ -20,7 +20,8 @@ import { export enum StratosCurrentUserPermissions { - ENDPOINT_REGISTER = 'register.endpoint', + EDIT_ENDPOINT = 'edit-endpoint', + EDIT_ADMIN_ENDPOINT = 'edit-admin-endpoint', PASSWORD_CHANGE = 'change-password', EDIT_PROFILE = 'edit-profile', /** @@ -35,12 +36,12 @@ export enum StratosPermissionStrings { STRATOS_ADMIN = 'isAdmin' } - export enum StratosScopeStrings { STRATOS_CHANGE_PASSWORD = 'password.write', SCIM_READ = 'scim.read', SCIM_WRITE = 'scim.write', - STRATOS_NOAUTH = 'stratos.noauth' + STRATOS_NOAUTH = 'stratos.noauth', + STRATOS_ENDPOINTADMIN = 'stratos.endpointadmin' } export enum StratosPermissionTypes { @@ -53,7 +54,11 @@ export enum StratosPermissionTypes { // Every group result must be true in order for the permission to be true. A group result is true if all or some of it's permissions are // true (see `getCheckFromConfig`). export const stratosPermissionConfigs: IPermissionConfigs = { - [StratosCurrentUserPermissions.ENDPOINT_REGISTER]: new PermissionConfig( + [StratosCurrentUserPermissions.EDIT_ENDPOINT]: new PermissionConfig( + StratosPermissionTypes.STRATOS_SCOPE, + StratosScopeStrings.STRATOS_ENDPOINTADMIN + ), + [StratosCurrentUserPermissions.EDIT_ADMIN_ENDPOINT]: new PermissionConfig( StratosPermissionTypes.STRATOS, StratosPermissionStrings.STRATOS_ADMIN ), diff --git a/src/frontend/packages/core/src/core/user.service.ts b/src/frontend/packages/core/src/core/user.service.ts index 9add961da8..20c46eeb01 100644 --- a/src/frontend/packages/core/src/core/user.service.ts +++ b/src/frontend/packages/core/src/core/user.service.ts @@ -10,10 +10,18 @@ import { AuthOnlyAppState } from '../../../store/src/app-state'; export class UserService { isAdmin$: Observable; + isEndpointAdmin$: Observable; constructor(store: Store) { this.isAdmin$ = store.select(s => s.auth).pipe( map((auth: AuthState) => auth.sessionData && auth.sessionData.user && auth.sessionData.user.admin)); + + this.isEndpointAdmin$ = store.select(s => s.auth).pipe( + map((auth: AuthState) => { + return (auth.sessionData + && auth.sessionData.user + && auth.sessionData.user.scopes.find(e => e === 'stratos.endpointadmin') !== undefined); + })); } } diff --git a/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-cf-step-1/create-endpoint-cf-step-1.component.html b/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-cf-step-1/create-endpoint-cf-step-1.component.html index 1a25da9ffb..0beca26849 100644 --- a/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-cf-step-1/create-endpoint-cf-step-1.component.html +++ b/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-cf-step-1/create-endpoint-cf-step-1.component.html @@ -2,17 +2,22 @@

{{endpoint.definition.label}} Information

+ [appUnique]="registerForm.value.createSystemEndpointField ? (customEndpoints | async)?.names : (existingPersonalEndpoints | async)?.names"> Name is required Name is not unique + pattern="{{urlValidation}}" + [appUnique]="registerForm.value.createSystemEndpointField ? (customEndpoints | async)?.urls : (existingPersonalEndpoints | async)?.urls"> URL is required Invalid API URL URL is not unique + Create a system endpoint (visible to all users) + Skip SSL validation for the endpoint diff --git a/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-cf-step-1/create-endpoint-cf-step-1.component.spec.ts b/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-cf-step-1/create-endpoint-cf-step-1.component.spec.ts index ee8c879141..5573da963f 100644 --- a/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-cf-step-1/create-endpoint-cf-step-1.component.spec.ts +++ b/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-cf-step-1/create-endpoint-cf-step-1.component.spec.ts @@ -7,6 +7,7 @@ import { CoreTestingModule } from '../../../../../test-framework/core-test.modul import { CoreModule } from '../../../../core/core.module'; import { SharedModule } from '../../../../shared/shared.module'; import { CreateEndpointCfStep1Component } from './create-endpoint-cf-step-1.component'; +import { CurrentUserPermissionsService } from '../../../../core/permissions/current-user-permissions.service'; describe('CreateEndpointCfStep1Component', () => { let component: CreateEndpointCfStep1Component; @@ -29,8 +30,10 @@ describe('CreateEndpointCfStep1Component', () => { queryParams: {}, params: { type: 'metrics' } } - } - }] + }, + }, + CurrentUserPermissionsService + ] }) .compileComponents(); })); diff --git a/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-cf-step-1/create-endpoint-cf-step-1.component.ts b/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-cf-step-1/create-endpoint-cf-step-1.component.ts index 73b94605e9..013462f7af 100644 --- a/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-cf-step-1/create-endpoint-cf-step-1.component.ts +++ b/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-cf-step-1/create-endpoint-cf-step-1.component.ts @@ -2,9 +2,8 @@ import { AfterContentInit, Component, Input } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { Observable } from 'rxjs'; -import { filter, map, pairwise } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, pairwise } from 'rxjs/operators'; -import { getFullEndpointApiUrl } from '../../../../../../store/src/endpoint-utils'; import { entityCatalog } from '../../../../../../store/src/entity-catalog/entity-catalog'; import { StratosCatalogEndpointEntity, @@ -13,16 +12,20 @@ import { ActionState } from '../../../../../../store/src/reducers/api-request-re import { stratosEntityCatalog } from '../../../../../../store/src/stratos-entity-catalog'; import { getIdFromRoute } from '../../../../core/utils.service'; import { IStepperStep, StepOnNextFunction } from '../../../../shared/components/stepper/step/step.component'; +import { SessionService } from '../../../../shared/services/session.service'; +import { CurrentUserPermissionsService } from '../../../../core/permissions/current-user-permissions.service'; +import { UserProfileService } from '../../../../core/user-profile.service'; import { SnackBarService } from '../../../../shared/services/snackbar.service'; import { ConnectEndpointConfig } from '../../connect.service'; import { getSSOClientRedirectURI } from '../../endpoint-helpers'; +import { CreateEndpointHelperComponent } from '../create-endpoint-helper'; @Component({ selector: 'app-create-endpoint-cf-step-1', templateUrl: './create-endpoint-cf-step-1.component.html', styleUrls: ['./create-endpoint-cf-step-1.component.scss'] }) -export class CreateEndpointCfStep1Component implements IStepperStep, AfterContentInit { +export class CreateEndpointCfStep1Component extends CreateEndpointHelperComponent implements IStepperStep, AfterContentInit { registerForm: FormGroup; @@ -42,11 +45,6 @@ export class CreateEndpointCfStep1Component implements IStepperStep, AfterConten } } - existingEndpoints: Observable<{ - names: string[], - urls: string[], - }>; - validate: Observable; urlValidation: string; @@ -63,8 +61,13 @@ export class CreateEndpointCfStep1Component implements IStepperStep, AfterConten constructor( private fb: FormBuilder, activatedRoute: ActivatedRoute, - private snackBarService: SnackBarService + private snackBarService: SnackBarService, + sessionService: SessionService, + currentUserPermissionsService: CurrentUserPermissionsService, + userProfileService: UserProfileService ) { + super(sessionService, currentUserPermissionsService, userProfileService); + this.registerForm = this.fb.group({ nameField: ['', [Validators.required]], urlField: ['', [Validators.required]], @@ -73,15 +76,9 @@ export class CreateEndpointCfStep1Component implements IStepperStep, AfterConten // Optional Client ID and Client Secret clientIDField: ['', []], clientSecretField: ['', []], + createSystemEndpointField: [true, []], }); - this.existingEndpoints = stratosEntityCatalog.endpoint.store.getAll.getPaginationMonitor().currentPage$.pipe( - map(endpoints => ({ - names: endpoints.map(ep => ep.name), - urls: endpoints.map(ep => getFullEndpointApiUrl(ep)), - })) - ); - const epType = getIdFromRoute(activatedRoute, 'type'); const epSubType = getIdFromRoute(activatedRoute, 'subtype'); this.endpoint = entityCatalog.getEndpoint(epType, epSubType); @@ -102,6 +99,7 @@ export class CreateEndpointCfStep1Component implements IStepperStep, AfterConten this.registerForm.value.clientIDField, this.registerForm.value.clientSecretField, this.registerForm.value.ssoAllowedField, + this.registerForm.value.createSystemEndpointField, ).pipe( pairwise(), filter(([oldVal, newVal]) => (oldVal.busy && !newVal.busy)), @@ -151,4 +149,12 @@ export class CreateEndpointCfStep1Component implements IStepperStep, AfterConten toggleAdvancedOptions() { this.showAdvancedOptions = !this.showAdvancedOptions; } + + toggleCreateSystemEndpoint() { + // wait a tick for validators to adjust to new data in the directive + setTimeout(() => { + this.registerForm.controls.nameField.updateValueAndValidity(); + this.registerForm.controls.urlField.updateValueAndValidity(); + }); + } } diff --git a/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-helper.ts b/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-helper.ts new file mode 100644 index 0000000000..aa35943537 --- /dev/null +++ b/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-helper.ts @@ -0,0 +1,82 @@ +import { Observable, combineLatest } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { getFullEndpointApiUrl } from '../../../../../store/src/endpoint-utils'; +import { stratosEntityCatalog } from '../../../../../store/src/stratos-entity-catalog'; +import { CurrentUserPermissionsService } from '../../../core/permissions/current-user-permissions.service'; +import { StratosCurrentUserPermissions } from '../../../core/permissions/stratos-user-permissions.checker'; +import { UserProfileService } from '../../../core/user-profile.service'; +import { SessionService } from '../../../shared/services/session.service'; + +type EndpointObservable = Observable<{ + names: string[], + urls: string[], +}>; + +export class CreateEndpointHelperComponent { + + userEndpointsAndIsAdmin: Observable; + customEndpoints: EndpointObservable; + existingSystemEndpoints: EndpointObservable; + existingPersonalEndpoints: EndpointObservable; + existingEndpoints: EndpointObservable; + + constructor( + public sessionService: SessionService, + public currentUserPermissionsService: CurrentUserPermissionsService, + public userProfileService: UserProfileService + ) { + const currentPage$ = stratosEntityCatalog.endpoint.store.getAll.getPaginationMonitor().currentPage$; + this.existingSystemEndpoints = currentPage$.pipe( + map(endpoints => ({ + names: endpoints.filter(ep => ep.creator.system).map(ep => ep.name), + urls: endpoints.filter(ep => ep.creator.system).map(ep => getFullEndpointApiUrl(ep)), + })) + ); + this.existingPersonalEndpoints = combineLatest([ + currentPage$, + this.userProfileService.userProfile$ + ]).pipe( + map(([endpoints, profile]) => ({ + names: endpoints.filter(ep => !ep.creator.system && ep.creator.name === profile.userName).map(ep => ep.name), + urls: endpoints.filter(ep => !ep.creator.system && ep.creator.name === profile.userName).map(ep => getFullEndpointApiUrl(ep)), + })) + ); + this.existingEndpoints = currentPage$.pipe( + map(endpoints => ({ + names: endpoints.map(ep => ep.name), + urls: endpoints.map(ep => getFullEndpointApiUrl(ep)), + })) + ); + + const isAdmin = this.currentUserPermissionsService.can(StratosCurrentUserPermissions.EDIT_ADMIN_ENDPOINT); + const userEndpointsNotDisabled = this.sessionService.userEndpointsNotDisabled(); + + this.userEndpointsAndIsAdmin = combineLatest([ + isAdmin, + userEndpointsNotDisabled + ]).pipe( + map(([admin, userEndpoints]) => admin && userEndpoints) + ); + + this.customEndpoints = combineLatest([ + userEndpointsNotDisabled, + isAdmin, + this.existingEndpoints, + this.existingSystemEndpoints, + this.existingPersonalEndpoints + ]).pipe( + map(([userEndpointsEnabled, admin, endpoints, systemEndpoints, personalEndpoints]) => { + if (userEndpointsEnabled){ + if (admin){ + return systemEndpoints; + }else{ + return personalEndpoints; + } + } + return endpoints; + }) + ); + + } +} diff --git a/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint.component.spec.ts b/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint.component.spec.ts index a074af1f7b..14a0724331 100644 --- a/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint.component.spec.ts +++ b/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint.component.spec.ts @@ -47,8 +47,9 @@ describe('CreateEndpointComponent', () => { subtype: null } } - } + }, }, + CurrentUserPermissionsService, TabNavService, SidePanelService, CurrentUserPermissionsService diff --git a/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint.module.ts b/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint.module.ts index e00c1a62cb..f747ece74c 100644 --- a/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint.module.ts +++ b/src/frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint.module.ts @@ -20,13 +20,13 @@ import { CreateEndpointComponent } from './create-endpoint.component'; CreateEndpointCfStep1Component, CreateEndpointBaseStepComponent, CreateEndpointConnectComponent, - ConnectEndpointComponent + ConnectEndpointComponent, ], exports: [ CreateEndpointComponent, CreateEndpointCfStep1Component, CreateEndpointConnectComponent, - ConnectEndpointComponent + ConnectEndpointComponent, ] }) export class CreateEndpointModule { } diff --git a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.html b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.html index facd9c4be7..ba1bbe7abb 100644 --- a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.html +++ b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.html @@ -1,7 +1,7 @@

Endpoints

- diff --git a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.ts b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.ts index 7904575755..d69feadb7f 100644 --- a/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.ts +++ b/src/frontend/packages/core/src/features/endpoints/endpoints-page/endpoints-page.component.ts @@ -34,6 +34,7 @@ import { } from '../../../shared/components/list/list-types/endpoint/endpoints-list-config.service'; import { ListConfig } from '../../../shared/components/list/list.component.types'; import { SnackBarService } from '../../../shared/services/snackbar.service'; +import { SessionService } from '../../../shared/services/session.service'; @Component({ selector: 'app-endpoints-page', @@ -45,7 +46,7 @@ import { SnackBarService } from '../../../shared/services/snackbar.service'; }, EndpointListHelper] }) export class EndpointsPageComponent implements AfterViewInit, OnDestroy, OnInit { - public canRegisterEndpoint = StratosCurrentUserPermissions.ENDPOINT_REGISTER; + public canRegisterEndpoint: Observable; private healthCheckTimeout: number; public canBackupRestore$: Observable; @@ -68,6 +69,7 @@ export class EndpointsPageComponent implements AfterViewInit, OnDestroy, OnInit private snackBarService: SnackBarService, cs: CustomizationService, currentUserPermissionsService: CurrentUserPermissionsService, + public sessionService: SessionService ) { this.customizations = cs.get(); @@ -87,11 +89,21 @@ export class EndpointsPageComponent implements AfterViewInit, OnDestroy, OnInit first() ).subscribe(); + this.canRegisterEndpoint = this.sessionService.userEndpointsEnabled().pipe( + map(enabled => { + if (enabled){ + return [StratosCurrentUserPermissions.EDIT_ADMIN_ENDPOINT, StratosCurrentUserPermissions.EDIT_ENDPOINT]; + }else{ + return [StratosCurrentUserPermissions.EDIT_ADMIN_ENDPOINT]; + } + }) + ); + // Is the backup/restore plugin available on the backend? this.canBackupRestore$ = this.store.select(selectSessionData()).pipe( first(), map(sessionData => sessionData?.plugins.backup), - switchMap(enabled => enabled ? currentUserPermissionsService.can(this.canRegisterEndpoint) : of(false)) + switchMap(enabled => enabled ? currentUserPermissionsService.can(StratosCurrentUserPermissions.EDIT_ADMIN_ENDPOINT) : of(false)) ); } diff --git a/src/frontend/packages/core/src/jetstream.helpers.ts b/src/frontend/packages/core/src/jetstream.helpers.ts deleted file mode 100644 index 7de9c29e1e..0000000000 --- a/src/frontend/packages/core/src/jetstream.helpers.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { HttpErrorResponse } from '@angular/common/http'; - -import { isHttpErrorResponse, JetStreamErrorResponse } from '../../store/src/jetstream'; - diff --git a/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/base-endpoints-data-source.ts b/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/base-endpoints-data-source.ts index 22f224cbe5..cb329f65d0 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/base-endpoints-data-source.ts +++ b/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/base-endpoints-data-source.ts @@ -123,6 +123,11 @@ export class BaseEndpointsDataSource extends ListDataSource { system_shared_token: false, metricsAvailable: false, sso_allowed: false, + creator: { + name: '', + admin: false, + system: false + } }), paginationKey: action.paginationKey, isLocal: true, diff --git a/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/endpoint-card/endpoint-card.component.html b/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/endpoint-card/endpoint-card.component.html index e5e6cb868e..cd9de79533 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/endpoint-card/endpoint-card.component.html +++ b/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/endpoint-card/endpoint-card.component.html @@ -44,6 +44,12 @@
+ + Creator + +
{{ row.creator.name }}
+
+
Details diff --git a/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/endpoint-card/endpoint-card.component.ts b/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/endpoint-card/endpoint-card.component.ts index c9df05430d..06f27b7fe6 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/endpoint-card/endpoint-card.component.ts +++ b/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/endpoint-card/endpoint-card.component.ts @@ -9,7 +9,7 @@ import { ViewContainerRef, } from '@angular/core'; import { Store } from '@ngrx/store'; -import { Observable, of, ReplaySubject, Subscription } from 'rxjs'; +import { combineLatest, Observable, of, ReplaySubject, Subscription } from 'rxjs'; import { map, startWith } from 'rxjs/operators'; import { AppState } from '../../../../../../../../store/src/app-state'; @@ -32,6 +32,9 @@ import { BaseEndpointsDataSource } from '../base-endpoints-data-source'; import { EndpointListDetailsComponent, EndpointListHelper } from '../endpoint-list.helpers'; import { RouterNav } from './../../../../../../../../store/src/actions/router.actions'; import { CopyToClipboardComponent } from './../../../../copy-to-clipboard/copy-to-clipboard.component'; +import { SessionService } from '../../../../../services/session.service'; +import { CurrentUserPermissionsService } from '../../../../../../core/permissions/current-user-permissions.service'; +import { StratosCurrentUserPermissions } from '../../../../../../core/permissions/stratos-user-permissions.checker'; @Component({ selector: 'app-endpoint-card', @@ -54,6 +57,7 @@ export class EndpointCardComponent extends CardCell implements On public cardStatus$: Observable; private subs: Subscription[] = []; public connectionStatus: string; + public viewCreator$: Observable; private componentRef: ComponentRef; @@ -121,6 +125,8 @@ export class EndpointCardComponent extends CardCell implements On private endpointListHelper: EndpointListHelper, private componentFactoryResolver: ComponentFactoryResolver, private userFavoriteManager: UserFavoriteManager, + private currentUserPermissionsService: CurrentUserPermissionsService, + private sessionService: SessionService, ) { super(); this.endpointIds$ = this.endpointIds.asObservable(); @@ -130,6 +136,16 @@ export class EndpointCardComponent extends CardCell implements On this.favorite = this.userFavoriteManager.getFavoriteEndpointFromEntity(this.row); const e = this.endpointCatalogEntity.definition; this.hasDetails = !!e && !!e.listDetailsComponent; + this.viewCreator$ = combineLatest([ + this.sessionService.userEndpointsEnabled(), + this.sessionService.userEndpointsNotDisabled(), + this.currentUserPermissionsService.can(StratosCurrentUserPermissions.EDIT_ADMIN_ENDPOINT), + this.currentUserPermissionsService.can(StratosCurrentUserPermissions.EDIT_ENDPOINT) + ]).pipe( + map(([userEndpointsEnabled, userEndpointsNotDisabled, isAdmin, isEndpointAdmin]) => { + return (userEndpointsEnabled && (isAdmin || isEndpointAdmin)) || (userEndpointsNotDisabled && isAdmin); + }) + ); } ngOnDestroy(): void { diff --git a/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/endpoint-list.helpers.ts b/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/endpoint-list.helpers.ts index 37aff1a7a1..25b9d8d5ca 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/endpoint-list.helpers.ts +++ b/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/endpoint-list.helpers.ts @@ -15,6 +15,8 @@ import { StratosCurrentUserPermissions } from '../../../../../core/permissions/s import { ConnectEndpointDialogComponent, } from '../../../../../features/endpoints/connect-endpoint-dialog/connect-endpoint-dialog.component'; +import { SessionService } from '../../../../../shared/services/session.service'; +import { UserProfileService } from '../../../../../core/user-profile.service'; import { SnackBarService } from '../../../../services/snackbar.service'; import { ConfirmationDialogConfig } from '../../../confirmation-dialog.config'; import { ConfirmationDialogService } from '../../../confirmation-dialog.service'; @@ -66,6 +68,8 @@ export class EndpointListHelper { private currentUserPermissionsService: CurrentUserPermissionsService, private confirmDialog: ConfirmationDialogService, private snackBarService: SnackBarService, + private sessionService: SessionService, + private userProfileService: UserProfileService ) { } endpointActions(includeSeparators = false): IListAction[] { @@ -107,10 +111,10 @@ export class EndpointListHelper { }, label: 'Disconnect', description: ``, // Description depends on console user permission - createVisible: (row$: Observable) => combineLatest( - this.currentUserPermissionsService.can(StratosCurrentUserPermissions.ENDPOINT_REGISTER), + createVisible: (row$: Observable) => combineLatest([ + this.currentUserPermissionsService.can(StratosCurrentUserPermissions.EDIT_ADMIN_ENDPOINT), row$ - ).pipe( + ]).pipe( map(([isAdmin, row]) => { const isConnected = row.connectionStatus === 'connected'; return isConnected && (!row.system_shared_token || row.system_shared_token && isAdmin); @@ -132,11 +136,25 @@ export class EndpointListHelper { }, label: 'Connect', description: '', - createVisible: (row$: Observable) => row$.pipe(map(row => { - const endpoint = entityCatalog.getEndpoint(row.cnsi_type, row.sub_type); - const ep = endpoint ? endpoint.definition : { unConnectable: false }; - return !ep.unConnectable && row.connectionStatus === 'disconnected'; - })) + createVisible: (row$: Observable) => { + return combineLatest([ + this.sessionService.userEndpointsNotDisabled(), + this.userProfileService.userProfile$, + row$ + ]).pipe( + map(([userEndpointsEnabled, profile, row]) => { + if (userEndpointsEnabled && !row.creator.system && profile.userName !== row.creator.name) { + // Disable connect for admins if the endpoint was not created by them. Otherwise this could result in an admin connecting to + // multiple user endpoints that all have the same url. + return false; + } else { + const endpoint = entityCatalog.getEndpoint(row.cnsi_type, row.sub_type); + const ep = endpoint ? endpoint.definition : { unConnectable: false }; + return !ep.unConnectable && row.connectionStatus === 'disconnected'; + } + }) + ); + } }, { action: (item) => { @@ -155,7 +173,24 @@ export class EndpointListHelper { }, label: 'Unregister', description: 'Remove the endpoint', - createVisible: () => this.currentUserPermissionsService.can(StratosCurrentUserPermissions.ENDPOINT_REGISTER) + createVisible: (row$: Observable) => { + // I think if we end up using these often there should be specific create, + // edit, delete style permissions in the stratos permissions checker + return combineLatest([ + this.sessionService.userEndpointsNotDisabled(), + this.currentUserPermissionsService.can(StratosCurrentUserPermissions.EDIT_ADMIN_ENDPOINT), + this.currentUserPermissionsService.can(StratosCurrentUserPermissions.EDIT_ENDPOINT), + row$ + ]).pipe( + map(([userEndpointsEnabled, isAdmin, isEndpointAdmin, row]) => { + if (!userEndpointsEnabled || row.creator.system) { + return isAdmin; + } else { + return isEndpointAdmin || isAdmin; + } + }) + ); + } }, { action: (item) => { @@ -164,7 +199,22 @@ export class EndpointListHelper { }, label: 'Edit endpoint', description: 'Edit the endpoint', - createVisible: () => this.currentUserPermissionsService.can(StratosCurrentUserPermissions.ENDPOINT_REGISTER) + createVisible: (row$: Observable) => { + return combineLatest([ + this.sessionService.userEndpointsNotDisabled(), + this.currentUserPermissionsService.can(StratosCurrentUserPermissions.EDIT_ADMIN_ENDPOINT), + this.currentUserPermissionsService.can(StratosCurrentUserPermissions.EDIT_ENDPOINT), + row$ + ]).pipe( + map(([userEndpointsEnabled, isAdmin, isEndpointAdmin, row]) => { + if (!userEndpointsEnabled || row.creator.system) { + return isAdmin; + } else { + return isEndpointAdmin || isAdmin; + } + }) + ); + } }, ...customActions ]; diff --git a/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/endpoints-list-config.service.ts b/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/endpoints-list-config.service.ts index 24fb90718b..ebdbf37e7e 100644 --- a/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/endpoints-list-config.service.ts +++ b/src/frontend/packages/core/src/shared/components/list/list-types/endpoint/endpoints-list-config.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { BehaviorSubject, combineLatest, of } from 'rxjs'; -import { debounceTime, filter, map } from 'rxjs/operators'; +import { debounceTime, filter, first, map } from 'rxjs/operators'; import { ListView } from '../../../../../../../store/src/actions/list.actions'; import { SetClientFilter } from '../../../../../../../store/src/actions/pagination.actions'; @@ -14,6 +14,9 @@ import { stratosEntityCatalog } from '../../../../../../../store/src/stratos-ent import { EndpointModel } from '../../../../../../../store/src/types/endpoint.types'; import { PaginationEntityState } from '../../../../../../../store/src/types/pagination.types'; import { UserFavoriteManager } from '../../../../../../../store/src/user-favorite-manager'; +import { SessionService } from '../../../../services/session.service'; +import { CurrentUserPermissionsService } from '../../../../../core/permissions/current-user-permissions.service'; +import { StratosCurrentUserPermissions } from '../../../../../core/permissions/stratos-user-permissions.checker'; import { createTableColumnFavorite } from '../../list-table/table-cell-favorite/table-cell-favorite.component'; import { ITableColumn } from '../../list-table/table.types'; import { @@ -114,13 +117,42 @@ export class EndpointsListConfigService implements IListConfig { entityMonitorFactory: EntityMonitorFactory, internalEventMonitorFactory: InternalEventMonitorFactory, endpointListHelper: EndpointListHelper, - userFavoriteManager: UserFavoriteManager + userFavoriteManager: UserFavoriteManager, + currentUserPermissionsService: CurrentUserPermissionsService, + sessionService: SessionService ) { this.singleActions = endpointListHelper.endpointActions(); const favoriteCell = createTableColumnFavorite( (row: EndpointModel) => userFavoriteManager.getFavoriteEndpointFromEntity(row) ); this.columns.push(favoriteCell); + combineLatest([ + sessionService.userEndpointsEnabled(), + sessionService.userEndpointsNotDisabled(), + currentUserPermissionsService.can(StratosCurrentUserPermissions.EDIT_ADMIN_ENDPOINT), + currentUserPermissionsService.can(StratosCurrentUserPermissions.EDIT_ENDPOINT) + ]).pipe( + first(), + map(([userEndpointsEnabled, userEndpointsNotDisabled, isAdmin, isEndpointAdmin]) => { + return (userEndpointsEnabled && (isAdmin || isEndpointAdmin)) || (userEndpointsNotDisabled && isAdmin); + }) + ).subscribe(enabled => { + if (enabled) { + this.columns.splice(4, 0, { + columnId: 'creator', + headerCell: () => 'Creator', + cellDefinition: { + valuePath: 'creator.name' + }, + sort: { + type: 'sort', + orderKey: 'creator', + field: 'creator.name' + }, + cellFlex: '2' + }); + } + }); this.dataSource = new EndpointsDataSource( this.store, @@ -201,4 +233,5 @@ export class EndpointsListConfigService implements IListConfig { ); } } + } diff --git a/src/frontend/packages/core/src/shared/services/session.service.ts b/src/frontend/packages/core/src/shared/services/session.service.ts index 9990d0fed5..d218b96bd4 100644 --- a/src/frontend/packages/core/src/shared/services/session.service.ts +++ b/src/frontend/packages/core/src/shared/services/session.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; +import { UserEndpointsEnabled } from 'frontend/packages/store/src/types/auth.types'; import { Observable } from 'rxjs'; import { first, map } from 'rxjs/operators'; @@ -17,4 +18,18 @@ export class SessionService { map(sessionData => sessionData.config.enableTechPreview || false) ); } + + userEndpointsEnabled(): Observable { + return this.store.select(selectSessionData()).pipe( + first(), + map(sessionData => sessionData && sessionData.config.userEndpointsEnabled === UserEndpointsEnabled.ENABLED) + ); + } + + userEndpointsNotDisabled(): Observable { + return this.store.select(selectSessionData()).pipe( + first(), + map(sessionData => sessionData && sessionData.config.userEndpointsEnabled !== UserEndpointsEnabled.DISABLED) + ); + } } diff --git a/src/frontend/packages/core/src/shared/user-permission.directive.ts b/src/frontend/packages/core/src/shared/user-permission.directive.ts index 7b633408fb..1effeda211 100644 --- a/src/frontend/packages/core/src/shared/user-permission.directive.ts +++ b/src/frontend/packages/core/src/shared/user-permission.directive.ts @@ -1,5 +1,6 @@ import { Directive, Input, OnDestroy, OnInit, TemplateRef, ViewContainerRef } from '@angular/core'; -import { Subscription } from 'rxjs'; +import { combineLatest, Observable, Subscription } from 'rxjs'; +import { map } from 'rxjs/operators'; import { PermissionTypes } from '../core/permissions/current-user-permissions.config'; import { CurrentUserPermissionsService } from '../core/permissions/current-user-permissions.service'; @@ -9,7 +10,7 @@ import { CurrentUserPermissionsService } from '../core/permissions/current-user- }) export class UserPermissionDirective implements OnDestroy, OnInit { @Input() - public appUserPermission: PermissionTypes; + public appUserPermission: PermissionTypes[]; @Input() public appUserPermissionEndpointGuid: string; @@ -23,9 +24,24 @@ export class UserPermissionDirective implements OnDestroy, OnInit { ) { } public ngOnInit() { - this.canSub = this.currentUserPermissionsService.can( - this.appUserPermission, - this.appUserPermissionEndpointGuid, + // execute a permission check for every give permissiontype + let $permissionChecks: Observable[]; + if (this.appUserPermission) { + $permissionChecks = this.appUserPermission.map((permission: PermissionTypes) => { + return this.currentUserPermissionsService.can(permission, this.appUserPermissionEndpointGuid); + }); + } + + // permit user if one check results true + this.canSub = combineLatest($permissionChecks).pipe( + map((arr: boolean[]) => { + for (const result of arr){ + if (result){ + return result; + } + } + return false; + }) ).subscribe(can => { if (can) { this.viewContainer.createEmbeddedView(this.templateRef); diff --git a/src/frontend/packages/git/src/shared/components/git-registration/git-registration.component.html b/src/frontend/packages/git/src/shared/components/git-registration/git-registration.component.html index 64ef2fb0c1..ba6373518f 100644 --- a/src/frontend/packages/git/src/shared/components/git-registration/git-registration.component.html +++ b/src/frontend/packages/git/src/shared/components/git-registration/git-registration.component.html @@ -19,18 +19,22 @@

Select the type of {{gitTypes[epSubType].label}} to register

+ [appUnique]="registerForm.value.createSystemEndpointField ? (customEndpoints | async)?.names : (existingPersonalEndpoints | async)?.names"> Name is required Name is not unique + [appUnique]="registerForm.value.createSystemEndpointField ? (customEndpoints | async)?.urls : (existingPersonalEndpoints | async)?.urls"> URL is required Invalid API URL URL is not unique + Create a system endpoint (visible to all users) + Skip SSL validation for the endpoint diff --git a/src/frontend/packages/git/src/shared/components/git-registration/git-registration.component.spec.ts b/src/frontend/packages/git/src/shared/components/git-registration/git-registration.component.spec.ts index 55c747e3fc..d5ab6a319d 100644 --- a/src/frontend/packages/git/src/shared/components/git-registration/git-registration.component.spec.ts +++ b/src/frontend/packages/git/src/shared/components/git-registration/git-registration.component.spec.ts @@ -2,10 +2,11 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute } from '@angular/router'; import { SharedModule } from '@stratosui/core'; import { getGitHubAPIURL, gitEntityCatalog, GITHUB_API_URL, GitSCMService } from '@stratosui/git'; -import { CATALOGUE_ENTITIES } from '@stratosui/store'; +import { CATALOGUE_ENTITIES, entityCatalog, TestEntityCatalog } from '@stratosui/store'; import { BaseTestModulesNoShared } from '../../../../../core/test-framework/core-test.helper'; import { GitRegistrationComponent } from './git-registration.component'; +import { generateStratosEntities } from '../../../../../store/src/stratos-entity-generator'; describe('GitRegistrationComponent', () => { let component: GitRegistrationComponent; @@ -21,7 +22,16 @@ describe('GitRegistrationComponent', () => { providers: [ { provide: GITHUB_API_URL, useFactory: getGitHubAPIURL }, GitSCMService, - { provide: CATALOGUE_ENTITIES, useFactory: () => gitEntityCatalog.allGitEntities(), multi: false }, + { + provide: CATALOGUE_ENTITIES, useFactory: () => { + const testEntityCatalog = entityCatalog as TestEntityCatalog; + testEntityCatalog.clear(); + return [ + ...generateStratosEntities(), + ...gitEntityCatalog.allGitEntities() + ]; + }, multi: false + }, { provide: ActivatedRoute, useValue: { diff --git a/src/frontend/packages/git/src/shared/components/git-registration/git-registration.component.ts b/src/frontend/packages/git/src/shared/components/git-registration/git-registration.component.ts index 19cdca1276..2ab2d3bab8 100644 --- a/src/frontend/packages/git/src/shared/components/git-registration/git-registration.component.ts +++ b/src/frontend/packages/git/src/shared/components/git-registration/git-registration.component.ts @@ -1,6 +1,9 @@ import { Component, OnDestroy } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; +import { + CreateEndpointHelperComponent, +} from 'frontend/packages/core/src/features/endpoints/create-endpoint/create-endpoint-helper'; import { Observable, Subscription } from 'rxjs'; import { filter, first, map, pairwise } from 'rxjs/operators'; @@ -8,6 +11,9 @@ import { EndpointsService } from '../../../../../core/src/core/endpoints.service import { getIdFromRoute } from '../../../../../core/src/core/utils.service'; import { ConnectEndpointConfig } from '../../../../../core/src/features/endpoints/connect.service'; import { StepOnNextFunction } from '../../../../../core/src/shared/components/stepper/step/step.component'; +import { SessionService } from '../../../../../core/src/shared/services/session.service'; +import { CurrentUserPermissionsService } from '../../../../../core/src/core/permissions/current-user-permissions.service'; +import { UserProfileService } from '../../../../../core/src/core/user-profile.service'; import { SnackBarService } from '../../../../../core/src/shared/services/snackbar.service'; import { getFullEndpointApiUrl } from '../../../../../store/src/endpoint-utils'; import { entityCatalog } from '../../../../../store/src/public-api'; @@ -49,7 +55,7 @@ enum GitTypeKeys { templateUrl: './git-registration.component.html', styleUrls: ['./git-registration.component.scss'], }) -export class GitRegistrationComponent implements OnDestroy { +export class GitRegistrationComponent extends CreateEndpointHelperComponent implements OnDestroy { public gitTypes: EndpointSubTypes; @@ -71,7 +77,11 @@ export class GitRegistrationComponent implements OnDestroy { private fb: FormBuilder, private snackBarService: SnackBarService, private endpointsService: EndpointsService, + public sessionService: SessionService, + public currentUserPermissionsService: CurrentUserPermissionsService, + public userProfileService: UserProfileService ) { + super(sessionService, currentUserPermissionsService, userProfileService); this.epSubType = getIdFromRoute(activatedRoute, 'subtype'); const githubLabel = entityCatalog.getEndpoint(GIT_ENDPOINT_TYPE, GIT_ENDPOINT_SUB_TYPES.GITHUB).definition.label || 'Github'; const gitlabLabel = entityCatalog.getEndpoint(GIT_ENDPOINT_TYPE, GIT_ENDPOINT_SUB_TYPES.GITLAB).definition.label || 'Gitlab'; @@ -151,6 +161,7 @@ export class GitRegistrationComponent implements OnDestroy { nameField: ['', [Validators.required]], urlField: ['', [Validators.required]], skipSllField: [false, []], + createSystemEndpointField: [true, []], }); this.updateType(); @@ -192,8 +203,10 @@ export class GitRegistrationComponent implements OnDestroy { const skipSSL = this.registerForm.controls.nameField.value && this.registerForm.controls.urlField.value ? this.registerForm.controls.skipSllField.value : false; + const createSystemEndpoint = this.registerForm.controls.createSystemEndpointField.value; - return stratosEntityCatalog.endpoint.api.register(GIT_ENDPOINT_TYPE, this.epSubType, name, url, skipSSL, '', '', false) + return stratosEntityCatalog.endpoint.api.register(GIT_ENDPOINT_TYPE, + this.epSubType, name, url, skipSSL, '', '', false, createSystemEndpoint) .pipe( pairwise(), filter(([oldVal, newVal]) => (oldVal.busy && !newVal.busy)), @@ -228,4 +241,12 @@ export class GitRegistrationComponent implements OnDestroy { const ready = urlTrimmed[urlTrimmed.length - 1] === '/' ? urlTrimmed.substring(0, urlTrimmed.length - 1) : urlTrimmed; return ready + '/' + defn.urlSuffix; } + + toggleCreateSystemEndpoint() { + // wait a tick for validators to adjust to new data in the directive + setTimeout(() => { + this.registerForm.controls.nameField.updateValueAndValidity(); + this.registerForm.controls.urlField.updateValueAndValidity(); + }); + } } diff --git a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-usage/chart-details-usage.component.spec.ts b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-usage/chart-details-usage.component.spec.ts index dcd88dc1ab..b6af842d7f 100644 --- a/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-usage/chart-details-usage.component.spec.ts +++ b/src/frontend/packages/kubernetes/src/helm/monocular/chart-details/chart-details-usage/chart-details-usage.component.spec.ts @@ -1,5 +1,6 @@ import { NO_ERRORS_SCHEMA } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { SessionService } from '../../../../../../core/src/shared/services/session.service'; import { EndpointsService } from '../../../../../../core/src/core/endpoints.service'; import { UtilsService } from '../../../../../../core/src/core/utils.service'; @@ -15,7 +16,8 @@ describe('Component: ChartDetailsUsage', () => { providers: [ EndpointsService, UtilsService, - PaginationMonitorFactory + PaginationMonitorFactory, + SessionService ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); diff --git a/src/frontend/packages/store/src/actions/endpoint.actions.ts b/src/frontend/packages/store/src/actions/endpoint.actions.ts index ac3ccdddae..372da47c61 100644 --- a/src/frontend/packages/store/src/actions/endpoint.actions.ts +++ b/src/frontend/packages/store/src/actions/endpoint.actions.ts @@ -215,6 +215,7 @@ export class RegisterEndpoint extends SingleBaseEndpointAction { public clientID = '', public clientSecret = '', public ssoAllowed: boolean, + public createSystemEndpoint: boolean, ) { super( REGISTER_ENDPOINTS, diff --git a/src/frontend/packages/store/src/effects/endpoint.effects.ts b/src/frontend/packages/store/src/effects/endpoint.effects.ts index af1a2d7757..fff7653c04 100644 --- a/src/frontend/packages/store/src/effects/endpoint.effects.ts +++ b/src/frontend/packages/store/src/effects/endpoint.effects.ts @@ -194,7 +194,8 @@ export class EndpointsEffect { skip_ssl_validation: action.skipSslValidation ? 'true' : 'false', cnsi_client_id: action.clientID, cnsi_client_secret: action.clientSecret, - sso_allowed: action.ssoAllowed ? 'true' : 'false' + sso_allowed: action.ssoAllowed ? 'true' : 'false', + create_system_endpoint: action.createSystemEndpoint ? 'true' : 'false' }; // Do not include sub_type in HttpParams if it doesn't exist (falsies get stringified and sent) if (action.endpointSubType) { @@ -252,9 +253,8 @@ export class EndpointsEffect { })); private processUpdateError(e: HttpErrorResponse): string { - const err = e.error ? e.error.error : {}; - let message = 'There was a problem updating the endpoint' + - `${err.error ? ' (' + err.error + ').' : ''}`; + let message = 'There was a problem updating the endpoint. ' + + httpErrorResponseToSafeString(e); if (e.status === 403) { message = `${message}. Please check \"Skip SSL validation for the endpoint\" if the certificate issuer is trusted`; } @@ -262,9 +262,8 @@ export class EndpointsEffect { } private processRegisterError(e: HttpErrorResponse): string { - let message = 'There was a problem creating the endpoint. ' + - `Please ensure the endpoint address is correct and try again` + - `${e.error.error ? ' (' + e.error.error + ').' : ''}`; + let message = 'There was a problem creating the endpoint. Please ensure the endpoint address is correct and try again. ' + + httpErrorResponseToSafeString(e); if (e.status === 403) { message = `${e.error.error}. Please check \"Skip SSL validation for the endpoint\" if the certificate issuer is trusted`; } diff --git a/src/frontend/packages/store/src/stratos-action-builders.ts b/src/frontend/packages/store/src/stratos-action-builders.ts index 39dc68a725..5bad71e652 100644 --- a/src/frontend/packages/store/src/stratos-action-builders.ts +++ b/src/frontend/packages/store/src/stratos-action-builders.ts @@ -60,6 +60,7 @@ export interface EndpointActionBuilder extends OrchestratedActionBuilders { clientID?: string, clientSecret?: string, ssoAllowed?: boolean, + createSystemEndpointField?: boolean, ) => RegisterEndpoint; update: ( guid: string, @@ -104,6 +105,7 @@ export const endpointActionBuilder: EndpointActionBuilder = { clientID?: string, clientSecret?: string, ssoAllowed?: boolean, + createSystemEndpoint?: boolean, ) => new RegisterEndpoint( endpointType, endpointSubType, @@ -113,6 +115,7 @@ export const endpointActionBuilder: EndpointActionBuilder = { clientID, clientSecret, ssoAllowed, + createSystemEndpoint, ), update: ( guid: string, diff --git a/src/frontend/packages/store/src/types/auth.types.ts b/src/frontend/packages/store/src/types/auth.types.ts index e9730e6b3b..c12d9cd835 100644 --- a/src/frontend/packages/store/src/types/auth.types.ts +++ b/src/frontend/packages/store/src/types/auth.types.ts @@ -27,6 +27,20 @@ export enum APIKeysEnabled { ADMIN_ONLY = 'admin_only', ALL_USERS = 'all_users' } +export enum UserEndpointsEnabled { + /** + * No users can see or create their own endpoints. Admins cannot see any previously created user endpoints. + */ + DISABLED = 'disabled', + /** + * No users can see or create their own endpoints. Admins can manage previously created user endpoints + */ + ADMIN_ONLY = 'admin_only', + /** + * Endpoint Admins can see and create their own endpoints. Admins can manage all user endpoints + */ + ENABLED = 'enabled' +} export interface SessionDataConfig { enableTechPreview?: boolean; listMaxSize?: number; @@ -34,6 +48,7 @@ export interface SessionDataConfig { APIKeysEnabled?: APIKeysEnabled; // Default value for Home View - show only favorited endpoints? homeViewShowFavoritesOnly?: boolean; + userEndpointsEnabled?: UserEndpointsEnabled; } export interface SessionData { endpoints?: SessionEndpoints; diff --git a/src/frontend/packages/store/src/types/endpoint.types.ts b/src/frontend/packages/store/src/types/endpoint.types.ts index 93cc4c831d..feb3c51906 100644 --- a/src/frontend/packages/store/src/types/endpoint.types.ts +++ b/src/frontend/packages/store/src/types/endpoint.types.ts @@ -49,6 +49,7 @@ export interface EndpointModel { connectionStatus?: endpointConnectionStatus; metricsAvailable: boolean; local?: true; + creator: CreatorInfo; } export const SystemSharedUserGuid = '00000000-1111-2222-3333-444444444444'; @@ -63,6 +64,13 @@ export interface EndpointUser { scopes?: UserScopeStrings[]; } +// Metadata for the user who created an endpoint +export interface CreatorInfo { + name: string; + admin: boolean; + system: boolean; +} + export interface EndpointState { loading: boolean; error: boolean; diff --git a/src/frontend/packages/store/testing/src/store-test-helper.ts b/src/frontend/packages/store/testing/src/store-test-helper.ts index 92440c6f7a..de91d34d68 100644 --- a/src/frontend/packages/store/testing/src/store-test-helper.ts +++ b/src/frontend/packages/store/testing/src/store-test-helper.ts @@ -46,7 +46,12 @@ export const testSCFEndpoint: EndpointModel = { cnsi_type: 'cf', system_shared_token: false, sso_allowed: false, - metricsAvailable: false + metricsAvailable: false, + creator: { + name: 'admin', + admin: true, + system: false + } }; export const testSessionData: SessionData = { @@ -299,6 +304,11 @@ function getDefaultInitialTestStoreState(): AppState { name: 'admin', admin: true }, + creator: { + name: 'admin', + admin: true, + system: false + }, connectionStatus: 'connected', system_shared_token: false, metricsAvailable: false diff --git a/src/jetstream/auth_test.go b/src/jetstream/auth_test.go index 75a3d6aff0..9df9c09794 100644 --- a/src/jetstream/auth_test.go +++ b/src/jetstream/auth_test.go @@ -2,12 +2,16 @@ package main import ( "errors" + "fmt" "net/http" + "net/http/httptest" "net/url" "strings" "testing" "time" + "github.com/golang/mock/gomock" + "github.com/labstack/echo/v4" uuid "github.com/satori/go.uuid" sqlmock "gopkg.in/DATA-DOG/go-sqlmock.v1" @@ -16,6 +20,8 @@ import ( "github.com/cloudfoundry-incubator/stratos/src/jetstream/crypto" "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces/config" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/mock_interfaces" . "github.com/smartystreets/goconvey/convey" ) @@ -369,8 +375,8 @@ func TestLoginToCNSI(t *testing.T) { DopplerLoggingEndpoint: mockDopplerEndpoint, } - expectedCNSIRow := sqlmock.NewRows([]string{"guid", "name", "cnsi_type", "api_endpoint", "auth_endpoint", "token_endpoint", "doppler_logging_endpoint", "skip_ssl_validation", "client_id", "client_secret", "allow_sso", "sub_type", "meta_data"}). - AddRow(mockCNSIGUID, mockCNSI.Name, stringCFType, mockUAA.URL, mockCNSI.AuthorizationEndpoint, mockCNSI.TokenEndpoint, mockCNSI.DopplerLoggingEndpoint, true, mockCNSI.ClientId, cipherClientSecret, true, "", "") + expectedCNSIRow := sqlmock.NewRows([]string{"guid", "name", "cnsi_type", "api_endpoint", "auth_endpoint", "token_endpoint", "doppler_logging_endpoint", "skip_ssl_validation", "client_id", "client_secret", "allow_sso", "sub_type", "meta_data", ""}). + AddRow(mockCNSIGUID, mockCNSI.Name, stringCFType, mockUAA.URL, mockCNSI.AuthorizationEndpoint, mockCNSI.TokenEndpoint, mockCNSI.DopplerLoggingEndpoint, true, mockCNSI.ClientId, cipherClientSecret, true, "", "", "") mock.ExpectQuery(selectAnyFromCNSIs). WithArgs(mockCNSIGUID). @@ -385,6 +391,16 @@ func TestLoginToCNSI(t *testing.T) { t.Error(errors.New("unable to mock/stub user in session object")) } + //Init the auth service + err := pp.InitStratosAuthService(interfaces.AuthEndpointTypes[pp.Config.ConsoleConfig.AuthEndpointType]) + if err != nil { + log.Warnf("%v, defaulting to auth type: remote", err) + err = pp.InitStratosAuthService(interfaces.Remote) + if err != nil { + log.Fatalf("Could not initialise auth service: %v", err) + } + } + mock.ExpectQuery(selectAnyFromTokens). WithArgs(mockCNSIGUID, mockUserGUID). WillReturnRows(sqlmock.NewRows([]string{"COUNT(*)"}).AddRow("0")) @@ -560,7 +576,234 @@ func TestLoginToCNSIWithBadUserIDinSession(t *testing.T) { So(mock.ExpectationsWereMet(), ShouldBeNil) }) }) +} + +func TestLoginToCNSIWithUserEndpointsEnabled(t *testing.T) { + t.Parallel() + + Convey("Login to CNSI with UserEndpoints enabled", t, func() { + // mock StratosAuthService + ctrl := gomock.NewController(t) + mockStratosAuth := mock_interfaces.NewMockStratosAuth(ctrl) + defer ctrl.Finish() + + // setup mock DB, PortalProxy and mock StratosAuthService + pp, db, mock := setupPortalProxyWithAuthService(mockStratosAuth) + defer db.Close() + + pp.GetConfig().UserEndpointsEnabled = config.UserEndpointsConfigEnum.Enabled + + mockUAA := setupMockServer(t, + msRoute("/oauth/token"), + msMethod("POST"), + msStatus(http.StatusOK), + msBody(jsonMust(mockUAAResponse))) + defer mockUAA.Close() + + mockAdmin := setupMockUser(mockAdminGUID, true, []string{}) + mockEndpointAdmin1 := setupMockUser(mockUserGUID+"1", false, []string{"stratos.endpointadmin"}) + mockEndpointAdmin2 := setupMockUser(mockUserGUID+"2", false, []string{"stratos.endpointadmin"}) + + // setup everything to mock a connection to an admin endpoint + adminEndpointArgs := createEndpointRowArgs("CF Endpoint 1", mockUAA.URL, mockUAA.URL, mockUAA.URL, mockAdmin.ConnectedUser.GUID, mockAdmin.ConnectedUser.Admin) + adminEndpointRows := sqlmock.NewRows(rowFieldsForCNSI).AddRow(adminEndpointArgs...) + + res := httptest.NewRecorder() + req := setupMockReq("POST", "", map[string]string{ + "username": "admin", + "password": "changeme", + "cnsi_guid": fmt.Sprintf("%v", adminEndpointArgs[0]), + }) + _, ctxConnectToAdmin := setupEchoContext(res, req) + // setup everything to mock a connection to an user endpoint + userEndpoint1Args := createEndpointRowArgs("CF Endpoint 2", mockUAA.URL, mockUAA.URL, mockUAA.URL, mockEndpointAdmin1.ConnectedUser.GUID, mockEndpointAdmin1.ConnectedUser.Admin) + userEndpoint1Rows := sqlmock.NewRows(rowFieldsForCNSI).AddRow(userEndpoint1Args...) + + res = httptest.NewRecorder() + req = setupMockReq("POST", "", map[string]string{ + "username": "admin", + "password": "changeme", + "cnsi_guid": fmt.Sprintf("%v", userEndpoint1Args[0]), + }) + _, ctxConnectToUser1 := setupEchoContext(res, req) + + // setup everything to mock a connection to a different user endpoint + userEndpoint2Args := createEndpointRowArgs("CF Endpoint 3", mockUAA.URL, mockUAA.URL, mockUAA.URL, mockEndpointAdmin2.ConnectedUser.GUID, mockEndpointAdmin2.ConnectedUser.Admin) + userEndpoint2Rows := sqlmock.NewRows(rowFieldsForCNSI).AddRow(userEndpoint2Args...) + + res = httptest.NewRecorder() + req = setupMockReq("POST", "", map[string]string{ + "username": "admin", + "password": "changeme", + "cnsi_guid": fmt.Sprintf("%v", userEndpoint2Args[0]), + }) + _, ctxConnectToUser2 := setupEchoContext(res, req) + + adminAndUserEndpointRows := sqlmock.NewRows(rowFieldsForCNSI).AddRow(adminEndpointArgs...).AddRow(userEndpoint1Args...) + + Convey("As admin", func() { + + Convey("Connect to system endpoint", func() { + if errSession := pp.setSessionValues(ctxConnectToAdmin, mockAdmin.SessionValues); errSession != nil { + t.Error(errors.New("unable to mock/stub user in session object")) + } + + mock.ExpectQuery(selectFromCNSIs).WillReturnRows(adminEndpointRows) + + mock.ExpectQuery(selectAnyFromCNSIs).WillReturnRows(adminEndpointRows) + + mock.ExpectQuery(selectAnyFromTokens). + WithArgs(adminEndpointArgs[0], mockAdmin.ConnectedUser.GUID). + WillReturnRows(sqlmock.NewRows([]string{"COUNT(*)"}).AddRow("0")) + + mock.ExpectExec(insertIntoTokens). + WillReturnResult(sqlmock.NewResult(1, 1)) + + err := pp.loginToCNSI(ctxConnectToAdmin) + dberr := mock.ExpectationsWereMet() + + Convey("there should be no error", func() { + So(err, ShouldBeNil) + }) + + Convey("there should be no db error", func() { + So(dberr, ShouldBeNil) + }) + }) + Convey("Connect to user endpoint", func() { + if errSession := pp.setSessionValues(ctxConnectToUser1, mockAdmin.SessionValues); errSession != nil { + t.Error(errors.New("unable to mock/stub user in session object")) + } + + mock.ExpectQuery(selectFromCNSIs).WillReturnRows(userEndpoint1Rows) + + err := pp.loginToCNSI(ctxConnectToUser1) + dberr := mock.ExpectationsWereMet() + + Convey("should fail", func() { + So(err, ShouldResemble, echo.NewHTTPError(http.StatusUnauthorized, "Can not connect - users are not allowed to connect to personal endpoints created by other users")) + }) + + Convey("there should be no db error", func() { + So(dberr, ShouldBeNil) + }) + }) + }) + Convey("As user", func() { + Convey("Connect to own endpoint", func() { + if errSession := pp.setSessionValues(ctxConnectToUser1, mockEndpointAdmin1.SessionValues); errSession != nil { + t.Error(errors.New("unable to mock/stub user in session object")) + } + + mock.ExpectQuery(selectFromCNSIs).WillReturnRows(userEndpoint1Rows) + + mock.ExpectQuery(selectAnyFromCNSIs).WillReturnRows(userEndpoint1Rows) + + mock.ExpectQuery(selectAnyFromTokens). + WithArgs(userEndpoint1Args[0], mockEndpointAdmin1.ConnectedUser.GUID). + WillReturnRows(sqlmock.NewRows([]string{"COUNT(*)"}).AddRow("0")) + + mock.ExpectExec(insertIntoTokens). + WillReturnResult(sqlmock.NewResult(1, 1)) + + err := pp.loginToCNSI(ctxConnectToUser1) + dberr := mock.ExpectationsWereMet() + + Convey("there should be no error", func() { + So(err, ShouldBeNil) + }) + + Convey("there should be no db error", func() { + So(dberr, ShouldBeNil) + }) + }) + Convey("Connect to own endpoint while already connected to same url with system endpoint", func() { + if errSession := pp.setSessionValues(ctxConnectToUser1, mockEndpointAdmin1.SessionValues); errSession != nil { + t.Error(errors.New("unable to mock/stub user in session object")) + } + + mock.ExpectQuery(selectFromCNSIs).WillReturnRows(userEndpoint1Rows) + + // args is the api url + mock.ExpectQuery(selectAnyFromCNSIs).WithArgs(userEndpoint1Args[3]).WillReturnRows(adminAndUserEndpointRows) + + // connected system endpoint found + mock.ExpectQuery(selectAnyFromTokens). + WithArgs(adminEndpointArgs[0], mockEndpointAdmin1.ConnectedUser.GUID, mockAdminGUID). + WillReturnRows(sqlmock.NewRows([]string{"token_guid", "auth_token", "refresh_token", "token_expiry", "disconnected", "auth_type", "meta_data", "user_guid", "linked_token"}). + AddRow("", mockUAAToken, mockUAAToken, time.Now().Add(-time.Hour).Unix(), false, "", "", "", nil)) + + // remove other connection, since it has the same api url + mock.ExpectExec(deleteTokens). + WithArgs(adminEndpointArgs[0], mockEndpointAdmin1.ConnectedUser.GUID). + WillReturnResult(sqlmock.NewResult(1, 1)) + + mock.ExpectQuery(selectAnyFromTokens). + WithArgs(userEndpoint1Args[0], mockEndpointAdmin1.ConnectedUser.GUID). + WillReturnRows(sqlmock.NewRows([]string{"COUNT(*)"}).AddRow("0")) + + mock.ExpectExec(insertIntoTokens). + WillReturnResult(sqlmock.NewResult(1, 1)) + + err := pp.loginToCNSI(ctxConnectToUser1) + dberr := mock.ExpectationsWereMet() + + Convey("there should be no error", func() { + So(err, ShouldBeNil) + }) + + Convey("there should be no db error", func() { + So(dberr, ShouldBeNil) + }) + }) + Convey("Connect to system endpoint", func() { + if errSession := pp.setSessionValues(ctxConnectToAdmin, mockEndpointAdmin1.SessionValues); errSession != nil { + t.Error(errors.New("unable to mock/stub user in session object")) + } + + mock.ExpectQuery(selectFromCNSIs).WillReturnRows(adminEndpointRows) + + mock.ExpectQuery(selectAnyFromCNSIs).WillReturnRows(adminEndpointRows) + + mock.ExpectQuery(selectAnyFromTokens). + WithArgs(adminEndpointArgs[0], mockEndpointAdmin1.ConnectedUser.GUID). + WillReturnRows(sqlmock.NewRows([]string{"COUNT(*)"}).AddRow("0")) + + mock.ExpectExec(insertIntoTokens). + WillReturnResult(sqlmock.NewResult(1, 1)) + + err := pp.loginToCNSI(ctxConnectToAdmin) + dberr := mock.ExpectationsWereMet() + + Convey("there should be no error", func() { + So(err, ShouldBeNil) + }) + + Convey("there should be no db error", func() { + So(dberr, ShouldBeNil) + }) + }) + Convey("Connect to endpoint created by another user", func() { + if errSession := pp.setSessionValues(ctxConnectToUser2, mockEndpointAdmin1.SessionValues); errSession != nil { + t.Error(errors.New("unable to mock/stub user in session object")) + } + + mock.ExpectQuery(selectFromCNSIs).WillReturnRows(userEndpoint2Rows) + + err := pp.loginToCNSI(ctxConnectToUser2) + dberr := mock.ExpectationsWereMet() + + Convey("should fail", func() { + So(err, ShouldResemble, echo.NewHTTPError(http.StatusUnauthorized, "Can not connect - users are not allowed to connect to personal endpoints created by other users")) + }) + + Convey("there should be no db error", func() { + So(dberr, ShouldBeNil) + }) + }) + }) + }) } func TestLogout(t *testing.T) { @@ -785,7 +1028,7 @@ func TestVerifySession(t *testing.T) { var expectedScopes = `"scopes":["openid","scim.read","cloud_controller.admin","uaa.user","cloud_controller.read","password.write","routing.router_groups.read","cloud_controller.write","doppler.firehose","scim.write"]` - var expectedBody = `{"status":"ok","error":"","data":{"version":{"proxy_version":"dev","database_version":20161117141922},"user":{"guid":"asd-gjfg-bob","name":"admin","admin":false,` + expectedScopes + `},"endpoints":{"cf":{}},"plugins":null,"config":{"enableTechPreview":false,"APIKeysEnabled":"admin_only","homeViewShowFavoritesOnly":false}}}` + var expectedBody = `{"status":"ok","error":"","data":{"version":{"proxy_version":"dev","database_version":20161117141922},"user":{"guid":"asd-gjfg-bob","name":"admin","admin":false,` + expectedScopes + `},"endpoints":{"cf":{}},"plugins":null,"config":{"enableTechPreview":false,"APIKeysEnabled":"admin_only","homeViewShowFavoritesOnly":false,"userEndpointsEnabled":"disabled"}}}` Convey("Should contain expected body", func() { So(res, ShouldNotBeNil) diff --git a/src/jetstream/authcnsi.go b/src/jetstream/authcnsi.go index 51ceeaece0..ba446ef2a4 100644 --- a/src/jetstream/authcnsi.go +++ b/src/jetstream/authcnsi.go @@ -12,6 +12,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces/config" "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/tokens" ) @@ -149,14 +150,42 @@ func (p *portalProxy) DoLoginToCNSI(c echo.Context, cnsiGUID string, systemShare return nil, echo.NewHTTPError(http.StatusUnauthorized, "Could not find correct session value") } + // admins are note allowed to connect to user created endpoints + if p.GetConfig().UserEndpointsEnabled != config.UserEndpointsConfigEnum.Disabled { + + if len(cnsiRecord.Creator) != 0 && cnsiRecord.Creator != userID { + return nil, echo.NewHTTPError(http.StatusUnauthorized, "Can not connect - users are not allowed to connect to personal endpoints created by other users") + } + + // search for system or personal endpoints and check if they are connected + // automatically disconnect other endpoint if already connected to same url + cnsiList, err := p.listCNSIByAPIEndpoint(cnsiRecord.APIEndpoint.String()) + if err != nil { + return nil, echo.NewHTTPError( + http.StatusBadRequest, + "Failed to retrieve list of CNSIs", + "Failed to retrieve list of CNSIs: %v", err, + ) + } + + for _, cnsi := range cnsiList { + if (cnsi.Creator == userID || len(cnsi.Creator) == 0) && cnsi.GUID != cnsiGUID { + _, ok := p.GetCNSITokenRecord(cnsi.GUID, userID) + if ok { + p.ClearCNSIToken(*cnsi, userID) + } + } + } + } + // Register as a system endpoint? if systemSharedToken { - // User needs to be an admin user, err := p.StratosAuthService.GetUser(userID) if err != nil { return nil, echo.NewHTTPError(http.StatusUnauthorized, "Can not connect System Shared endpoint - could not check user") } + // User needs to be an admin if !user.Admin { return nil, echo.NewHTTPError(http.StatusUnauthorized, "Can not connect System Shared endpoint - user is not an administrator") } diff --git a/src/jetstream/cnsi.go b/src/jetstream/cnsi.go index 0952daba1a..ea1e95dcc4 100644 --- a/src/jetstream/cnsi.go +++ b/src/jetstream/cnsi.go @@ -17,6 +17,7 @@ import ( "github.com/cloudfoundry-incubator/stratos/src/jetstream/plugins/userfavorites/userfavoritesendpoints" "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces/config" ) const dbReferenceError = "Unable to establish a database reference: '%v'" @@ -62,12 +63,26 @@ func (p *portalProxy) RegisterEndpoint(c echo.Context, fetchInfo interfaces.Info cnsiClientSecret := params.CNSIClientSecret subType := params.SubType + createSystemEndpoint, err := strconv.ParseBool(params.CreateSystemEndpoint) + if err != nil { + // default to true + createSystemEndpoint = true + } + if cnsiClientId == "" { cnsiClientId = p.GetConfig().CFClient cnsiClientSecret = p.GetConfig().CFClientSecret } - newCNSI, err := p.DoRegisterEndpoint(params.CNSIName, params.APIEndpoint, skipSSLValidation, cnsiClientId, cnsiClientSecret, ssoAllowed, subType, fetchInfo) + userID, err := p.GetSessionStringValue(c, "user_id") + if err != nil { + return interfaces.NewHTTPShadowError( + http.StatusInternalServerError, + "Failed to get session user", + "Failed to get session user: %v", err) + } + + newCNSI, err := p.DoRegisterEndpoint(params.CNSIName, params.APIEndpoint, skipSSLValidation, cnsiClientId, cnsiClientSecret, userID, ssoAllowed, subType, createSystemEndpoint, fetchInfo) if err != nil { return err } @@ -76,7 +91,8 @@ func (p *portalProxy) RegisterEndpoint(c echo.Context, fetchInfo interfaces.Info return nil } -func (p *portalProxy) DoRegisterEndpoint(cnsiName string, apiEndpoint string, skipSSLValidation bool, clientId string, clientSecret string, ssoAllowed bool, subType string, fetchInfo interfaces.InfoFunc) (interfaces.CNSIRecord, error) { +func (p *portalProxy) DoRegisterEndpoint(cnsiName string, apiEndpoint string, skipSSLValidation bool, clientId string, clientSecret string, userId string, ssoAllowed bool, subType string, createSystemEndpoint bool, fetchInfo interfaces.InfoFunc) (interfaces.CNSIRecord, error) { + log.Debug("DoRegisterEndpoint") if len(cnsiName) == 0 || len(apiEndpoint) == 0 { return interfaces.CNSIRecord{}, interfaces.NewHTTPShadowError( @@ -96,17 +112,73 @@ func (p *portalProxy) DoRegisterEndpoint(cnsiName string, apiEndpoint string, sk "Failed to get API Endpoint: %v", err) } - // check if we've already got this endpoint in the DB - ok := p.cnsiRecordExists(apiEndpoint) - if ok { - // a record with the same api endpoint was found - return interfaces.CNSIRecord{}, interfaces.NewHTTPShadowError( - http.StatusBadRequest, - "Can not register same endpoint multiple times", - "Can not register same endpoint multiple times", - ) + isAdmin := true + + // anonymous admin? + if p.GetConfig().UserEndpointsEnabled != config.UserEndpointsConfigEnum.Disabled && len(userId) != 0 { + currentCreator, err := p.StratosAuthService.GetUser(userId) + if err != nil { + return interfaces.CNSIRecord{}, interfaces.NewHTTPShadowError( + http.StatusInternalServerError, + "Failed to get user information", + "Failed to get user information: %v", err) + } + isAdmin = currentCreator.Admin + } + + if p.GetConfig().UserEndpointsEnabled == config.UserEndpointsConfigEnum.Disabled { + // check if we've already got this endpoint in the DB + ok := p.adminCNSIRecordExists(apiEndpoint) + if ok { + // a record with the same api endpoint was found + return interfaces.CNSIRecord{}, interfaces.NewHTTPShadowError( + http.StatusBadRequest, + "Can not register same endpoint multiple times", + "Can not register same endpoint multiple times", + ) + } + } else { + // get all endpoints determined by the APIEndpoint + duplicateEndpoints, err := p.listCNSIByAPIEndpoint(apiEndpoint) + if err != nil { + return interfaces.CNSIRecord{}, interfaces.NewHTTPShadowError( + http.StatusBadRequest, + "Failed to check other endpoints", + "Failed to check other endpoints: %v", + err) + } + // check if we've already got this APIEndpoint in this DB + for _, duplicate := range duplicateEndpoints { + // cant create same system endpoint + if len(duplicate.Creator) == 0 && isAdmin && createSystemEndpoint { + return interfaces.CNSIRecord{}, interfaces.NewHTTPShadowError( + http.StatusBadRequest, + "Can not register same system endpoint multiple times", + "Can not register same system endpoint multiple times", + ) + } + + // cant create same user endpoint + if duplicate.Creator == userId { + return interfaces.CNSIRecord{}, interfaces.NewHTTPShadowError( + http.StatusBadRequest, + "Can not register same endpoint multiple times", + "Can not register same endpoint multiple times", + ) + } + } } + h := sha1.New() + // see why its generated this way in Issue #4753 / #3031 + if p.GetConfig().UserEndpointsEnabled != config.UserEndpointsConfigEnum.Disabled && (!isAdmin || (isAdmin && !createSystemEndpoint)) { + // Make the new guid unique per api url AND user id + h.Write([]byte(apiEndpointURL.String() + userId)) + } else { + h.Write([]byte(apiEndpointURL.String())) + } + guid := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + newCNSI, _, err := fetchInfo(apiEndpoint, skipSSLValidation) if err != nil { if ok, detail := isSSLRelatedError(err); ok { @@ -123,10 +195,6 @@ func (p *portalProxy) DoRegisterEndpoint(cnsiName string, apiEndpoint string, sk err) } - h := sha1.New() - h.Write([]byte(apiEndpointURL.String())) - guid := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) - newCNSI.Name = cnsiName newCNSI.APIEndpoint = apiEndpointURL newCNSI.SkipSSLValidation = skipSSLValidation @@ -135,6 +203,10 @@ func (p *portalProxy) DoRegisterEndpoint(cnsiName string, apiEndpoint string, sk newCNSI.SSOAllowed = ssoAllowed newCNSI.SubType = subType + if p.GetConfig().UserEndpointsEnabled != config.UserEndpointsConfigEnum.Disabled && (!isAdmin || !createSystemEndpoint) { + newCNSI.Creator = userId + } + err = p.setCNSIRecord(guid, newCNSI) // set the guid on the object so it's returned in the response @@ -173,6 +245,13 @@ func (p *portalProxy) unregisterCluster(c echo.Context) error { "Missing target endpoint", "Need CNSI GUID passed as form param") } + + return p.doUnregisterCluster(cnsiGUID) +} + +func (p *portalProxy) doUnregisterCluster(cnsiGUID string) error { + log.Debug("doUnregisterCluster") + // Should check for errors? p.unsetCNSIRecord(cnsiGUID) @@ -186,7 +265,55 @@ func (p *portalProxy) unregisterCluster(c echo.Context) error { func (p *portalProxy) buildCNSIList(c echo.Context) ([]*interfaces.CNSIRecord, error) { log.Debug("buildCNSIList") - return p.ListEndpoints() + + if p.GetConfig().UserEndpointsEnabled != config.UserEndpointsConfigEnum.Disabled { + userID, err := p.GetSessionValue(c, "user_id") + if err != nil { + return nil, err + } + + u, err := p.StratosAuthService.GetUser(userID.(string)) + if err != nil { + return nil, err + } + + if u.Admin { + return p.ListEndpoints() + } + + if p.GetConfig().UserEndpointsEnabled != config.UserEndpointsConfigEnum.AdminOnly { + // if endpoint with same url exists as system and user endpoint, hide the system endpoint + unfilteredList, err := p.ListAdminEndpoints(userID.(string)) + if err != nil { + return unfilteredList, err + } + + filteredList := []*interfaces.CNSIRecord{} + + for _, endpoint := range unfilteredList { + duplicateSystemEndpoint := false + duplicateEndpointIndex := -1 + + for i := 0; i < len(filteredList); i++ { + if filteredList[i].APIEndpoint.String() == endpoint.APIEndpoint.String() { + duplicateSystemEndpoint = len(filteredList[i].Creator) == 0 + duplicateEndpointIndex = i + } + } + + if duplicateEndpointIndex != -1 && !u.Admin { + if duplicateSystemEndpoint { + filteredList[duplicateEndpointIndex] = endpoint + } + } else { + filteredList = append(filteredList, endpoint) + } + } + + return filteredList, err + } + } + return p.ListAdminEndpoints("") } func (p *portalProxy) ListEndpoints() ([]*interfaces.CNSIRecord, error) { @@ -207,6 +334,60 @@ func (p *portalProxy) ListEndpoints() ([]*interfaces.CNSIRecord, error) { return cnsiList, nil } +// ListAdminEndpoints - return a CNSI list with endpoints created by the current user and all admins +func (p *portalProxy) ListAdminEndpoints(userID string) ([]*interfaces.CNSIRecord, error) { + log.Debug("ListAdminEndpoints") + // Initialise cnsiList to ensure empty struct (marshals to null) is not returned + cnsiList := []*interfaces.CNSIRecord{} + var userList []string + var err error + + userList = append(userList, userID) + if len(userID) != 0 { + userList = append(userList, "") + } + + //get a cnsi list from every admin found and given userID + cnsiRepo, err := p.GetStoreFactory().EndpointStore() + if err != nil { + return cnsiList, fmt.Errorf("listRegisteredCNSIs: %s", err) + } + + for _, id := range userList { + creatorList, err := cnsiRepo.ListByCreator(id, p.Config.EncryptionKeyInBytes) + if err != nil { + return creatorList, err + } + cnsiList = append(cnsiList, creatorList...) + } + return cnsiList, nil +} + +// listCNSIByAPIEndpoint - receives a URL as string +func (p *portalProxy) listCNSIByAPIEndpoint(apiEndpoint string) ([]*interfaces.CNSIRecord, error) { + log.Debug("listCNSIByAPIEndpoint") + + var err error + cnsiList := []*interfaces.CNSIRecord{} + + cnsiRepo, err := p.GetStoreFactory().EndpointStore() + if err != nil { + return cnsiList, fmt.Errorf("listCNSIByAPIEndpoint: %s", err) + } + + cnsiList, err = cnsiRepo.ListByAPIEndpoint(apiEndpoint, p.Config.EncryptionKeyInBytes) + if err != nil { + return cnsiList, err + } + + for _, cnsi := range cnsiList { + // Ensure that trailing slash is removed from the API Endpoint + cnsi.APIEndpoint.Path = strings.TrimRight(cnsi.APIEndpoint.Path, "/") + } + + return cnsiList, nil +} + // listCNSIs godoc // @Summary List endpoints // @Description @@ -340,30 +521,36 @@ func (p *portalProxy) GetCNSIRecord(guid string) (interfaces.CNSIRecord, error) return rec, nil } -func (p *portalProxy) GetCNSIRecordByEndpoint(endpoint string) (interfaces.CNSIRecord, error) { - log.Debug("GetCNSIRecordByEndpoint") - var rec interfaces.CNSIRecord +func (p *portalProxy) GetAdminCNSIRecordByEndpoint(endpoint string) (interfaces.CNSIRecord, error) { + log.Debug("GetAdminCNSIRecordByEndpoint") + var rec *interfaces.CNSIRecord - cnsiRepo, err := p.GetStoreFactory().EndpointStore() + endpointList, err := p.listCNSIByAPIEndpoint(endpoint) if err != nil { - return rec, err + return interfaces.CNSIRecord{}, err } - rec, err = cnsiRepo.FindByAPIEndpoint(endpoint, p.Config.EncryptionKeyInBytes) - if err != nil { - return rec, err + // search for endpoint created by an admin + for _, endpoint := range endpointList { + if len(endpoint.Creator) == 0 { + rec = endpoint + } + } + + if rec == nil { + return interfaces.CNSIRecord{}, fmt.Errorf("Can not find admin CNSIRecord by given endpoint") } // Ensure that trailing slash is removed from the API Endpoint rec.APIEndpoint.Path = strings.TrimRight(rec.APIEndpoint.Path, "/") - return rec, nil + return *rec, nil } -func (p *portalProxy) cnsiRecordExists(endpoint string) bool { - log.Debug("cnsiRecordExists") +func (p *portalProxy) adminCNSIRecordExists(apiEndpoint string) bool { + log.Debug("adminCNSIRecordExists") - _, err := p.GetCNSIRecordByEndpoint(endpoint) + _, err := p.GetAdminCNSIRecordByEndpoint(apiEndpoint) return err == nil } diff --git a/src/jetstream/cnsi_test.go b/src/jetstream/cnsi_test.go index 75d06549d2..a365890d3a 100644 --- a/src/jetstream/cnsi_test.go +++ b/src/jetstream/cnsi_test.go @@ -3,10 +3,16 @@ package main import ( "errors" "net/http" + "net/http/httptest" "testing" + "time" "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/interfaces/config" + "github.com/cloudfoundry-incubator/stratos/src/jetstream/repository/mock_interfaces" + "github.com/golang/mock/gomock" _ "github.com/satori/go.uuid" + . "github.com/smartystreets/goconvey/convey" "gopkg.in/DATA-DOG/go-sqlmock.v1" ) @@ -32,8 +38,17 @@ func TestRegisterCFCluster(t *testing.T) { _, _, ctx, pp, db, mock := setupHTTPTest(req) defer db.Close() + // Set a dummy userid in session - normally the login to UAA would do this. + sessionValues := make(map[string]interface{}) + sessionValues["user_id"] = mockUserGUID + sessionValues["exp"] = time.Now().AddDate(0, 0, 1).Unix() + + if errSession := pp.setSessionValues(ctx, sessionValues); errSession != nil { + t.Error(errors.New("unable to mock/stub user in session object")) + } + mock.ExpectExec(insertIntoCNSIs). - WithArgs(sqlmock.AnyArg(), "Some fancy CF Cluster", "cf", mockV2Info.URL, mockAuthEndpoint, mockTokenEndpoint, mockDopplerEndpoint, true, mockClientId, sqlmock.AnyArg(), false, "", ""). + WithArgs(sqlmock.AnyArg(), "Some fancy CF Cluster", "cf", mockV2Info.URL, mockAuthEndpoint, mockTokenEndpoint, mockDopplerEndpoint, true, mockClientId, sqlmock.AnyArg(), false, "", "", ""). WillReturnResult(sqlmock.NewResult(1, 1)) if err := pp.RegisterEndpoint(ctx, getCFPlugin(pp, "cf").Info); err != nil { @@ -254,3 +269,501 @@ func TestGetCFv2InfoWithInvalidEndpoint(t *testing.T) { t.Error("getCFv2Info should not return a valid response when the endpoint is invalid.") } } + +func TestRegisterWithUserEndpointsEnabled(t *testing.T) { + // execute this in parallel + t.Parallel() + + Convey("Request to register endpoint", t, func() { + + // mock StratosAuthService + ctrl := gomock.NewController(t) + mockStratosAuth := mock_interfaces.NewMockStratosAuth(ctrl) + defer ctrl.Finish() + + // setup mock DB, PortalProxy and mock StratosAuthService + pp, db, mock := setupPortalProxyWithAuthService(mockStratosAuth) + defer db.Close() + + // mock individual APIEndpoints + mockV2Info := []*httptest.Server{} + for i := 0; i < 1; i++ { + server := setupMockServer(t, + msRoute("/v2/info"), + msMethod("GET"), + msStatus(http.StatusOK), + msBody(jsonMust(mockV2InfoResponse))) + defer server.Close() + mockV2Info = append(mockV2Info, server) + } + + // mock different users + mockAdmin := setupMockUser(mockAdminGUID, true, []string{}) + mockUser1 := setupMockUser(mockUserGUID+"1", false, []string{"stratos.endpointadmin"}) + mockUser2 := setupMockUser(mockUserGUID+"2", false, []string{"stratos.endpointadmin"}) + + Convey("with UserEndpointsEnabled=enabled", func() { + + pp.GetConfig().UserEndpointsEnabled = config.UserEndpointsConfigEnum.Enabled + + Convey("as admin", func() { + Convey("with createSystemEndpoint enabled", func() { + // setup + adminEndpoint := setupMockEndpointRegisterRequest(t, mockAdmin.ConnectedUser, mockV2Info[0], "CF Cluster 1", true, true) + + if errSession := pp.setSessionValues(adminEndpoint.EchoContext, mockAdmin.SessionValues); errSession != nil { + t.Error(errors.New("unable to mock/stub user in session object")) + } + Convey("register new endpoint", func() { + // mock executions + mockStratosAuth. + EXPECT(). + GetUser(gomock.Eq(mockAdmin.ConnectedUser.GUID)). + Return(mockAdmin.ConnectedUser, nil) + + // return no already saved endpoints + rows := sqlmock.NewRows(rowFieldsForCNSI) + mock.ExpectQuery(selectAnyFromCNSIs).WithArgs(mockV2Info[0].URL).WillReturnRows(rows) + + mock.ExpectExec(insertIntoCNSIs). + WithArgs(adminEndpoint.InsertArgs...). + WillReturnResult(sqlmock.NewResult(1, 1)) + + // test + err := pp.RegisterEndpoint(adminEndpoint.EchoContext, getCFPlugin(pp, "cf").Info) + dberr := mock.ExpectationsWereMet() + + Convey("there should be no error", func() { + So(err, ShouldBeNil) + }) + + Convey("there should be no db error", func() { + So(dberr, ShouldBeNil) + }) + }) + Convey("create system endpoint over existing user endpoints", func() { + // setup + userEndpoint := setupMockEndpointRegisterRequest(t, mockUser1.ConnectedUser, mockV2Info[0], "CF Cluster 1 User", false, false) + + // mock executions + mockStratosAuth. + EXPECT(). + GetUser(gomock.Eq(mockAdmin.ConnectedUser.GUID)). + Return(mockAdmin.ConnectedUser, nil) + + // return a user endpoint with same apiurl + rows := sqlmock.NewRows(rowFieldsForCNSI).AddRow(userEndpoint.QueryArgs...) + mock.ExpectQuery(selectAnyFromCNSIs).WithArgs(mockV2Info[0].URL).WillReturnRows(rows) + + // save cnsi + mock.ExpectExec(insertIntoCNSIs). + WithArgs(adminEndpoint.InsertArgs...). + WillReturnResult(sqlmock.NewResult(1, 1)) + + // test + err := pp.RegisterEndpoint(adminEndpoint.EchoContext, getCFPlugin(pp, "cf").Info) + dberr := mock.ExpectationsWereMet() + + Convey("there should be no error", func() { + So(err, ShouldBeNil) + }) + + Convey("there should be no db error", func() { + So(dberr, ShouldBeNil) + }) + }) + Convey("create system endpoint over existing system endpoints", func() { + // mock executions + mockStratosAuth. + EXPECT(). + GetUser(gomock.Eq(mockAdmin.ConnectedUser.GUID)). + Return(mockAdmin.ConnectedUser, nil) + + // return a admin endpoint with same apiurl + rows := sqlmock.NewRows(rowFieldsForCNSI).AddRow(adminEndpoint.QueryArgs...) + mock.ExpectQuery(selectAnyFromCNSIs).WithArgs(mockV2Info[0].URL).WillReturnRows(rows) + + // test + err := pp.RegisterEndpoint(adminEndpoint.EchoContext, getCFPlugin(pp, "cf").Info) + dberr := mock.ExpectationsWereMet() + + Convey("should fail ", func() { + So(err, ShouldResemble, interfaces.NewHTTPShadowError( + http.StatusBadRequest, + "Can not register same system endpoint multiple times", + "Can not register same system endpoint multiple times", + )) + }) + + Convey("no insert should be executed", func() { + So(dberr, ShouldBeNil) + }) + }) + }) + Convey("with createSystemEndpoint disabled", func() { + + // setup + adminEndpoint := setupMockEndpointRegisterRequest(t, mockAdmin.ConnectedUser, mockV2Info[0], "CF Cluster 1", false, false) + systemEndpoint := setupMockEndpointRegisterRequest(t, mockAdmin.ConnectedUser, mockV2Info[0], "CF Cluster 1", false, true) + + if errSession := pp.setSessionValues(adminEndpoint.EchoContext, mockAdmin.SessionValues); errSession != nil { + t.Error(errors.New("unable to mock/stub user in session object")) + } + + Convey("register personal endpoint over system endpoint", func() { + // mock executions + mockStratosAuth. + EXPECT(). + GetUser(gomock.Eq(mockAdmin.ConnectedUser.GUID)). + Return(mockAdmin.ConnectedUser, nil) + + // return a admin endpoint with same apiurl + rows := sqlmock.NewRows(rowFieldsForCNSI).AddRow(systemEndpoint.QueryArgs...) + mock.ExpectQuery(selectAnyFromCNSIs).WithArgs(mockV2Info[0].URL).WillReturnRows(rows) + + // save cnsi + mock.ExpectExec(insertIntoCNSIs). + WithArgs(adminEndpoint.InsertArgs...). + WillReturnResult(sqlmock.NewResult(1, 1)) + + // test + err := pp.RegisterEndpoint(adminEndpoint.EchoContext, getCFPlugin(pp, "cf").Info) + dberr := mock.ExpectationsWereMet() + + Convey("there should be no error", func() { + So(err, ShouldBeNil) + }) + + Convey("there should be no db error", func() { + So(dberr, ShouldBeNil) + }) + }) + Convey("register personal endpoint twice", func() { + // mock executions + mockStratosAuth. + EXPECT(). + GetUser(gomock.Eq(mockAdmin.ConnectedUser.GUID)). + Return(mockAdmin.ConnectedUser, nil) + + // return a user endpoint with same apiurl + rows := sqlmock.NewRows(rowFieldsForCNSI).AddRow(adminEndpoint.QueryArgs...) + mock.ExpectQuery(selectAnyFromCNSIs).WithArgs(mockV2Info[0].URL).WillReturnRows(rows) + + // test + err := pp.RegisterEndpoint(adminEndpoint.EchoContext, getCFPlugin(pp, "cf").Info) + dberr := mock.ExpectationsWereMet() + + Convey("there should be no error", func() { + So(err, ShouldResemble, interfaces.NewHTTPShadowError( + http.StatusBadRequest, + "Can not register same endpoint multiple times", + "Can not register same endpoint multiple times", + )) + }) + + Convey("there should be no db error", func() { + So(dberr, ShouldBeNil) + }) + }) + }) + }) + + Convey("as user", func() { + Convey("with createSystemEndpoint enabled", func() { + // setup + userEndpoint := setupMockEndpointRegisterRequest(t, mockUser1.ConnectedUser, mockV2Info[0], "CF Cluster 1", true, false) + + if errSession := pp.setSessionValues(userEndpoint.EchoContext, mockUser1.SessionValues); errSession != nil { + t.Error(errors.New("unable to mock/stub user in session object")) + } + + Convey("register new endpoint", func() { + // mock executions + mockStratosAuth. + EXPECT(). + GetUser(gomock.Eq(mockUser1.ConnectedUser.GUID)). + Return(mockUser1.ConnectedUser, nil) + + rows := sqlmock.NewRows(rowFieldsForCNSI) + mock.ExpectQuery(selectAnyFromCNSIs).WithArgs(mockV2Info[0].URL).WillReturnRows(rows) + + mock.ExpectExec(insertIntoCNSIs). + WithArgs(userEndpoint.InsertArgs...). + WillReturnResult(sqlmock.NewResult(1, 1)) + + err := pp.RegisterEndpoint(userEndpoint.EchoContext, getCFPlugin(pp, "cf").Info) + dberr := mock.ExpectationsWereMet() + + Convey("there should be no error", func() { + So(err, ShouldBeNil) + }) + + Convey("there should be no db error", func() { + So(dberr, ShouldBeNil) + }) + }) + Convey("register existing endpoint from different user", func() { + userEndpoint2 := setupMockEndpointRegisterRequest(t, mockUser2.ConnectedUser, mockV2Info[0], "CF Cluster 2", false, false) + + // mock executions + mockStratosAuth. + EXPECT(). + GetUser(gomock.Eq(mockUser1.ConnectedUser.GUID)). + Return(mockUser1.ConnectedUser, nil) + + rows := sqlmock.NewRows(rowFieldsForCNSI).AddRow(userEndpoint2.QueryArgs...) + mock.ExpectQuery(selectAnyFromCNSIs).WithArgs(mockV2Info[0].URL).WillReturnRows(rows) + + mock.ExpectExec(insertIntoCNSIs). + WithArgs(userEndpoint.InsertArgs...). + WillReturnResult(sqlmock.NewResult(1, 1)) + + err := pp.RegisterEndpoint(userEndpoint.EchoContext, getCFPlugin(pp, "cf").Info) + dberr := mock.ExpectationsWereMet() + + Convey("there should be no error", func() { + So(err, ShouldBeNil) + }) + + Convey("there should be no db error", func() { + So(dberr, ShouldBeNil) + }) + }) + Convey("register existing endpoint from same user", func() { + // mock executions + mockStratosAuth. + EXPECT(). + GetUser(gomock.Eq(mockUser1.ConnectedUser.GUID)). + Return(mockUser1.ConnectedUser, nil) + + rows := sqlmock.NewRows(rowFieldsForCNSI).AddRow(userEndpoint.QueryArgs...) + mock.ExpectQuery(selectAnyFromCNSIs).WithArgs(mockV2Info[0].URL).WillReturnRows(rows) + + err := pp.RegisterEndpoint(userEndpoint.EchoContext, getCFPlugin(pp, "cf").Info) + dberr := mock.ExpectationsWereMet() + + Convey("should fail ", func() { + So(err, ShouldResemble, interfaces.NewHTTPShadowError( + http.StatusBadRequest, + "Can not register same endpoint multiple times", + "Can not register same endpoint multiple times", + )) + }) + + Convey("there should be no db error", func() { + So(dberr, ShouldBeNil) + }) + }) + }) + Convey("with createSystemEndpoint disabled", func() { + userEndpoint := setupMockEndpointRegisterRequest(t, mockUser1.ConnectedUser, mockV2Info[0], "CF Cluster 1", false, false) + + if errSession := pp.setSessionValues(userEndpoint.EchoContext, mockUser1.SessionValues); errSession != nil { + t.Error(errors.New("unable to mock/stub user in session object")) + } + Convey("register existing endpoint from same user", func() { + // mock executions + mockStratosAuth. + EXPECT(). + GetUser(gomock.Eq(mockUser1.ConnectedUser.GUID)). + Return(mockUser1.ConnectedUser, nil) + + rows := sqlmock.NewRows(rowFieldsForCNSI).AddRow(userEndpoint.QueryArgs...) + mock.ExpectQuery(selectAnyFromCNSIs).WithArgs(mockV2Info[0].URL).WillReturnRows(rows) + + err := pp.RegisterEndpoint(userEndpoint.EchoContext, getCFPlugin(pp, "cf").Info) + dberr := mock.ExpectationsWereMet() + + Convey("should fail ", func() { + So(err, ShouldResemble, interfaces.NewHTTPShadowError( + http.StatusBadRequest, + "Can not register same endpoint multiple times", + "Can not register same endpoint multiple times", + )) + }) + + Convey("there should be no db error", func() { + So(dberr, ShouldBeNil) + }) + }) + }) + + }) + }) + }) +} + +func TestListCNSIsWithUserEndpointsEnabled(t *testing.T) { + t.Parallel() + + Convey("Request to list endpoints", t, func() { + + // mock StratosAuthService + ctrl := gomock.NewController(t) + mockStratosAuth := mock_interfaces.NewMockStratosAuth(ctrl) + defer ctrl.Finish() + + // setup mock DB, PortalProxy and mock StratosAuthService + pp, db, mock := setupPortalProxyWithAuthService(mockStratosAuth) + defer db.Close() + + // setup request + + res := httptest.NewRecorder() + req := setupMockReq("GET", "", nil) + _, ctx := setupEchoContext(res, req) + + mockAdmin := setupMockUser(mockAdminGUID, true, []string{}) + mockUser1 := setupMockUser(mockUserGUID+"1", false, []string{"stratos.endpointadmin"}) + mockUser2 := setupMockUser(mockUserGUID+"2", false, []string{"stratos.endpointadmin"}) + + adminEndpointArgs := createEndpointRowArgs("CF Endpoint 1", "https://127.0.0.1:50001", mockAuthEndpoint, mockTokenEndpoint, mockAdmin.ConnectedUser.GUID, mockAdmin.ConnectedUser.Admin) + userEndpoint1Args := createEndpointRowArgs("CF Endpoint 2", "https://127.0.0.1:50002", mockAuthEndpoint, mockTokenEndpoint, mockUser1.ConnectedUser.GUID, mockUser1.ConnectedUser.Admin) + userEndpoint2Args := createEndpointRowArgs("CF Endpoint 3", "https://127.0.0.1:50003", mockAuthEndpoint, mockTokenEndpoint, mockUser2.ConnectedUser.GUID, mockUser2.ConnectedUser.Admin) + + adminRows := sqlmock.NewRows(rowFieldsForCNSI). + AddRow(adminEndpointArgs...) + user1Rows := sqlmock.NewRows(rowFieldsForCNSI). + AddRow(userEndpoint1Args...) + allRows := sqlmock.NewRows(rowFieldsForCNSI). + AddRow(adminEndpointArgs...). + AddRow(userEndpoint1Args...). + AddRow(userEndpoint2Args...) + + Convey("as admin", func() { + + if errSession := pp.setSessionValues(ctx, mockAdmin.SessionValues); errSession != nil { + t.Error(errors.New("unable to mock/stub user in session object")) + } + + Convey("with UserEndpointsEnabled = enabled", func() { + //expect list all + pp.GetConfig().UserEndpointsEnabled = config.UserEndpointsConfigEnum.Enabled + + mockStratosAuth. + EXPECT(). + GetUser(gomock.Eq(mockAdmin.ConnectedUser.GUID)). + Return(mockAdmin.ConnectedUser, nil) + + mock.ExpectQuery(selectFromCNSIs).WillReturnRows(allRows) + err := pp.listCNSIs(ctx) + dberr := mock.ExpectationsWereMet() + + Convey("there should be no error", func() { + So(err, ShouldBeNil) + }) + + Convey("there should be no db error", func() { + So(dberr, ShouldBeNil) + }) + }) + Convey("with UserEndpointsEnabled = admin_only", func() { + //expect list all + pp.GetConfig().UserEndpointsEnabled = config.UserEndpointsConfigEnum.AdminOnly + + mockStratosAuth. + EXPECT(). + GetUser(gomock.Eq(mockAdmin.ConnectedUser.GUID)). + Return(mockAdmin.ConnectedUser, nil) + + mock.ExpectQuery(selectFromCNSIs).WillReturnRows(allRows) + err := pp.listCNSIs(ctx) + dberr := mock.ExpectationsWereMet() + + Convey("there should be no error", func() { + So(err, ShouldBeNil) + }) + + Convey("there should be no db error", func() { + So(dberr, ShouldBeNil) + }) + + }) + Convey("with UserEndpointsEnabled = disabled", func() { + // expect list creator with "" + pp.GetConfig().UserEndpointsEnabled = config.UserEndpointsConfigEnum.Disabled + + mock.ExpectQuery(selectCreatorFromCNSIs).WithArgs("").WillReturnRows(adminRows) + err := pp.listCNSIs(ctx) + dberr := mock.ExpectationsWereMet() + + Convey("there should be no error", func() { + So(err, ShouldBeNil) + }) + + Convey("there should be no db error", func() { + So(dberr, ShouldBeNil) + }) + }) + + }) + Convey("as user", func() { + if errSession := pp.setSessionValues(ctx, mockUser1.SessionValues); errSession != nil { + t.Error(errors.New("unable to mock/stub user in session object")) + } + + Convey("with UserEndpointsEnabled = enabled", func() { + // expect list creator with "" and own endpoints + pp.GetConfig().UserEndpointsEnabled = config.UserEndpointsConfigEnum.Enabled + + mockStratosAuth. + EXPECT(). + GetUser(gomock.Eq(mockUser1.ConnectedUser.GUID)). + Return(mockUser1.ConnectedUser, nil) + + mock.ExpectQuery(selectCreatorFromCNSIs).WithArgs(mockUser1.ConnectedUser.GUID).WillReturnRows(user1Rows) + mock.ExpectQuery(selectCreatorFromCNSIs).WithArgs("").WillReturnRows(adminRows) + err := pp.listCNSIs(ctx) + dberr := mock.ExpectationsWereMet() + + Convey("there should be no error", func() { + So(err, ShouldBeNil) + }) + + Convey("there should be no db error", func() { + So(dberr, ShouldBeNil) + }) + + }) + Convey("with UserEndpointsEnabled = admin_only", func() { + // expect list creator with "" + pp.GetConfig().UserEndpointsEnabled = config.UserEndpointsConfigEnum.AdminOnly + + mockStratosAuth. + EXPECT(). + GetUser(gomock.Eq(mockUser1.ConnectedUser.GUID)). + Return(mockUser1.ConnectedUser, nil) + + mock.ExpectQuery(selectCreatorFromCNSIs).WithArgs("").WillReturnRows(adminRows) + err := pp.listCNSIs(ctx) + dberr := mock.ExpectationsWereMet() + + Convey("there should be no error", func() { + So(err, ShouldBeNil) + }) + + Convey("there should be no db error", func() { + So(dberr, ShouldBeNil) + }) + + }) + Convey("with UserEndpointsEnabled = disabled", func() { + // expect list creator with "" + pp.GetConfig().UserEndpointsEnabled = config.UserEndpointsConfigEnum.Disabled + + mock.ExpectQuery(selectCreatorFromCNSIs).WithArgs("").WillReturnRows(adminRows) + err := pp.listCNSIs(ctx) + dberr := mock.ExpectationsWereMet() + + Convey("there should be no error", func() { + So(err, ShouldBeNil) + }) + + Convey("there should be no db error", func() { + So(dberr, ShouldBeNil) + }) + }) + + }) + }) +} diff --git a/src/jetstream/config.dev b/src/jetstream/config.dev index dc5aca85cf..4087f02267 100644 --- a/src/jetstream/config.dev +++ b/src/jetstream/config.dev @@ -57,6 +57,10 @@ LOCAL_USER=admin LOCAL_USER_PASSWORD=admin LOCAL_USER_SCOPE=stratos.admin +# Enable users create user endpoints (disabled, admin_only, enabled). Default is disabled +# admin_only will enable admins to create or see user endpoints, but users won't be able to create user endpoints or see them +USER_ENDPOINTS_ENABLED=disabled + # Enable/disable API key-based access to Stratos API (disabled, admin_only, all_users). Default is admin_only #API_KEYS_ENABLED=admin_only diff --git a/src/jetstream/config.example b/src/jetstream/config.example index 1bffed8079..0b9de1830b 100644 --- a/src/jetstream/config.example +++ b/src/jetstream/config.example @@ -39,6 +39,10 @@ ENABLE_TECH_PREVIEW=false # By default, only show favorites endpoints on the home view # HOME_VIEW_SHOW_FAVORITES_ONLY=false +# Enable users create user endpoints (disabled, admin_only, enabled). Default is disabled +# admin_only will enable admins to create or see user endpoints, but users won't be able to create user endpoints or see them +USER_ENDPOINTS_ENABLED=disabled + # User Invites SMTP_FROM_ADDRESS=Stratos SMTP_HOST=127.0.0.1 diff --git a/src/jetstream/datastore/20210201110000_Creator.go b/src/jetstream/datastore/20210201110000_Creator.go new file mode 100644 index 0000000000..f5dda15cc1 --- /dev/null +++ b/src/jetstream/datastore/20210201110000_Creator.go @@ -0,0 +1,20 @@ +package datastore + +import ( + "database/sql" + + "bitbucket.org/liamstask/goose/lib/goose" +) + +func init() { + RegisterMigration(20210201110000, "Creator", func(txn *sql.Tx, conf *goose.DBConf) error { + alterCNSI := "ALTER TABLE cnsis ADD COLUMN creator VARCHAR(36) NOT NULL DEFAULT '';" + + _, err := txn.Exec(alterCNSI) + if err != nil { + return err + } + + return nil + }) +} diff --git a/src/jetstream/info.go b/src/jetstream/info.go index aaa8f1f204..5e00266a90 100644 --- a/src/jetstream/info.go +++ b/src/jetstream/info.go @@ -62,6 +62,7 @@ func (p *portalProxy) getInfo(c echo.Context) (*interfaces.Info, error) { s.Configuration.ListAllowLoadMaxed = p.Config.UIListAllowLoadMaxed s.Configuration.APIKeysEnabled = string(p.Config.APIKeysEnabled) s.Configuration.HomeViewShowFavoritesOnly = p.Config.HomeViewShowFavoritesOnly + s.Configuration.UserEndpointsEnabled = string(p.Config.UserEndpointsEnabled) // Only add diagnostics information if the user is an admin if uaaUser.Admin { @@ -98,6 +99,28 @@ func (p *portalProxy) getInfo(c echo.Context) (*interfaces.Info, error) { endpoint.TokenMetadata = token.Metadata endpoint.SystemSharedToken = token.SystemShared } + + // set the creator preemptively as admin, if no id is found + endpoint.Creator = &interfaces.CreatorInfo{ + Name: "System Endpoint", + Admin: false, + System: true, + } + + // assume it's a user when len != 0 + if len(cnsi.Creator) != 0 { + endpoint.Creator.System = false + u, err := p.StratosAuthService.GetUser(cnsi.Creator) + // add an anonymous user if no user is found + if err != nil { + endpoint.Creator.Name = "user" + endpoint.Creator.Admin = false + } else { + endpoint.Creator.Name = u.Name + endpoint.Creator.Admin = u.Admin + } + } + cnsiType := cnsi.CNSIType _, ok = s.Endpoints[cnsiType] diff --git a/src/jetstream/main.go b/src/jetstream/main.go index d384b12e07..6ad5f6d3fd 100644 --- a/src/jetstream/main.go +++ b/src/jetstream/main.go @@ -689,10 +689,16 @@ func newPortalProxy(pc interfaces.PortalConfig, dcp *sql.DB, ss HttpSessionStore // Setting default value for APIKeysEnabled if pc.APIKeysEnabled == "" { - log.Debug(`APIKeysEnabled not set, setting to "admin_only"`) + log.Info(`APIKeysEnabled not set, setting to "admin_only"`) pc.APIKeysEnabled = config.APIKeysConfigEnum.AdminOnly } + // Setting default value for UserEndpointsEnabled + if pc.UserEndpointsEnabled == "" { + log.Info(`UserEndpointsEnabled not set, setting to "disabled"`) + pc.UserEndpointsEnabled = config.UserEndpointsConfigEnum.Disabled + } + pp := &portalProxy{ Config: pc, DatabaseConnectionPool: dcp, @@ -1112,14 +1118,26 @@ func (p *portalProxy) registerRoutes(e *echo.Echo, needSetupMiddleware bool) { // API endpoints with Swagger documentation and accessible with an API key that require admin permissions stableAdminAPIGroup := stableAPIGroup - stableAdminAPIGroup.Use(p.adminMiddleware) - // route endpoint creation requests to respecive plugins - stableAdminAPIGroup.POST("/endpoints", p.pluginRegisterRouter) + // If path "/endpoints" is used, then stableAPIGroup.GET("/endpoints", p.listCNSIs) won't be executed anymore + // static html will be returned instead. That's why we use the path "" + stableEndpointAdminAPIGroup := stableAdminAPIGroup.Group("") + + if p.GetConfig().UserEndpointsEnabled == config.UserEndpointsConfigEnum.Enabled { + stableEndpointAdminAPIGroup.Use(p.endpointAdminMiddleware) + stableEndpointAdminAPIGroup.POST("/endpoints", p.pluginRegisterRouter) + // Use middleware in route directly, because documentation is faulty + // Apply middleware to group with .Use() when this issue is resolved: + // https://github.com/labstack/echo/issues/1519 + stableEndpointAdminAPIGroup.POST("/endpoints/:id", p.updateEndpoint, p.endpointUpdateDeleteMiddleware) + stableEndpointAdminAPIGroup.DELETE("/endpoints/:id", p.unregisterCluster, p.endpointUpdateDeleteMiddleware) + } else { + stableEndpointAdminAPIGroup.Use(p.adminMiddleware) + stableEndpointAdminAPIGroup.POST("/endpoints", p.pluginRegisterRouter) + stableEndpointAdminAPIGroup.POST("/endpoints/:id", p.updateEndpoint) + stableEndpointAdminAPIGroup.DELETE("/endpoints/:id", p.unregisterCluster) + } - // Apply edits for the given endpoint - stableAdminAPIGroup.POST("/endpoints/:id", p.updateEndpoint) - stableAdminAPIGroup.DELETE("/endpoints/:id", p.unregisterCluster) // sessionGroup.DELETE("/cnsis", p.removeCluster) // Serve up static resources diff --git a/src/jetstream/middleware.go b/src/jetstream/middleware.go index a2d7adce31..846cb841fa 100644 --- a/src/jetstream/middleware.go +++ b/src/jetstream/middleware.go @@ -252,6 +252,67 @@ func (p *portalProxy) adminMiddleware(h echo.HandlerFunc) echo.HandlerFunc { } } +// endpointAdminMiddleware - checks if user is admin or endpointadmin +func (p *portalProxy) endpointAdminMiddleware(h echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + log.Debug("endpointAdminMiddleware") + + userID, err := p.GetSessionValue(c, "user_id") + if err != nil { + return c.NoContent(http.StatusUnauthorized) + } + + u, err := p.StratosAuthService.GetUser(userID.(string)) + if err != nil { + return c.NoContent(http.StatusUnauthorized) + } + + endpointAdmin := strings.Contains(strings.Join(u.Scopes, ""), "stratos.endpointadmin") + + if endpointAdmin == false && u.Admin == false { + return handleSessionError(p.Config, c, errors.New("Unauthorized"), false, "You must be a Stratos admin or endpointAdmin to access this API") + } + + return h(c) + } +} + +// endpointUpdateDeleteMiddleware - checks if user has necessary permissions to modify endpoint +func (p *portalProxy) endpointUpdateDeleteMiddleware(h echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + log.Debug("endpointUpdateDeleteMiddleware") + userID, err := p.GetSessionValue(c, "user_id") + if err != nil { + return c.NoContent(http.StatusUnauthorized) + } + + u, err := p.StratosAuthService.GetUser(userID.(string)) + if err != nil { + return c.NoContent(http.StatusUnauthorized) + } + + endpointID := c.Param("id") + + cnsiRecord, err := p.GetCNSIRecord(endpointID) + if err != nil { + return c.NoContent(http.StatusUnauthorized) + } + + // endpoint created by admin when no id is saved + adminEndpoint := len(cnsiRecord.Creator) == 0 + + if adminEndpoint && !u.Admin { + return handleSessionError(p.Config, c, errors.New("Unauthorized"), false, "You must be Stratos admin to modify this endpoint.") + } + + if !adminEndpoint && !u.Admin && cnsiRecord.Creator != userID.(string) { + return handleSessionError(p.Config, c, errors.New("Unauthorized"), false, "EndpointAdmins are not allowed to modify endpoints created by other endpointAdmins.") + } + + return h(c) + } +} + func errorLoggingMiddleware(h echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { log.Debug("errorLoggingMiddleware") diff --git a/src/jetstream/middleware_test.go b/src/jetstream/middleware_test.go index eb061d7b1d..70b5f3ebcd 100644 --- a/src/jetstream/middleware_test.go +++ b/src/jetstream/middleware_test.go @@ -3,6 +3,7 @@ package main import ( "database/sql" "errors" + "fmt" "net/http" "net/http/httptest" "testing" @@ -376,3 +377,242 @@ func Test_apiKeyMiddleware(t *testing.T) { }) }) } + +func TestEndpointAdminMiddleware(t *testing.T) { + t.Parallel() + + Convey("new request through endpointAdminMiddleware", t, func() { + // mock StratosAuthService + ctrl := gomock.NewController(t) + mockStratosAuth := mock_interfaces.NewMockStratosAuth(ctrl) + defer ctrl.Finish() + + // setup mock DB, PortalProxy and mock StratosAuthService + pp, db, _ := setupPortalProxyWithAuthService(mockStratosAuth) + defer db.Close() + + handlerFunc := func(c echo.Context) error { + return c.String(http.StatusOK, "test") + } + + middleware := pp.endpointAdminMiddleware(handlerFunc) + + mockAdmin := setupMockUser(mockAdminGUID, true, []string{}) + mockEndpointAdmin := setupMockUser(mockUserGUID+"1", false, []string{"stratos.endpointadmin"}) + mockUser := setupMockUser(mockUserGUID+"2", false, []string{}) + + Convey("as admin", func() { + ctx, _ := makeNewRequestWithParams("POST", map[string]string{}) + + if errSession := pp.setSessionValues(ctx, mockAdmin.SessionValues); errSession != nil { + t.Error(errors.New("unable to mock/stub user in session object")) + } + + mockStratosAuth. + EXPECT(). + GetUser(gomock.Eq(mockAdmin.ConnectedUser.GUID)). + Return(mockAdmin.ConnectedUser, nil) + + err := middleware(ctx) + + Convey("request should be successful", func() { + So(err, ShouldBeNil) + }) + }) + + Convey("as endpointadmin", func() { + ctx, _ := makeNewRequestWithParams("POST", map[string]string{}) + + if errSession := pp.setSessionValues(ctx, mockEndpointAdmin.SessionValues); errSession != nil { + t.Error(errors.New("unable to mock/stub user in session object")) + } + + mockStratosAuth. + EXPECT(). + GetUser(gomock.Eq(mockEndpointAdmin.ConnectedUser.GUID)). + Return(mockEndpointAdmin.ConnectedUser, nil) + + err := middleware(ctx) + + Convey("request should be successful", func() { + So(err, ShouldBeNil) + }) + + }) + Convey("as normal user", func() { + ctx, _ := makeNewRequestWithParams("POST", map[string]string{}) + + if errSession := pp.setSessionValues(ctx, mockUser.SessionValues); errSession != nil { + t.Error(errors.New("unable to mock/stub user in session object")) + } + + mockStratosAuth. + EXPECT(). + GetUser(gomock.Eq(mockUser.ConnectedUser.GUID)). + Return(mockUser.ConnectedUser, nil) + + err := middleware(ctx) + + Convey("request should fail", func() { + So(err, + ShouldResemble, + handleSessionError(pp.Config, + ctx, + errors.New("Unauthorized"), + false, + "You must be a Stratos admin or endpointAdmin to access this API")) + }) + }) + }) +} + +func TestEndpointUpdateDeleteMiddleware(t *testing.T) { + t.Parallel() + + Convey("new request through endpointUpdateDeleteMiddleware", t, func() { + // mock StratosAuthService + ctrl := gomock.NewController(t) + mockStratosAuth := mock_interfaces.NewMockStratosAuth(ctrl) + defer ctrl.Finish() + + // setup mock DB, PortalProxy and mock StratosAuthService + pp, db, mock := setupPortalProxyWithAuthService(mockStratosAuth) + defer db.Close() + + res := httptest.NewRecorder() + req := setupMockReq("POST", "", nil) + _, ctx := setupEchoContext(res, req) + ctx.SetParamNames("id") + + handlerFunc := func(c echo.Context) error { + return c.String(http.StatusOK, "test") + } + middleware := pp.endpointUpdateDeleteMiddleware(handlerFunc) + + mockAdmin := setupMockUser(mockAdminGUID, true, []string{}) + mockEndpointAdmin1 := setupMockUser(mockUserGUID+"1", false, []string{"stratos.endpointadmin"}) + mockEndpointAdmin2 := setupMockUser(mockUserGUID+"2", false, []string{"stratos.endpointadmin"}) + + adminEndpointArgs := createEndpointRowArgs("CF Endpoint 1", "https://127.0.0.1:50001", mockAuthEndpoint, mockTokenEndpoint, mockAdmin.ConnectedUser.GUID, mockAdmin.ConnectedUser.Admin) + adminEndpointRows := sqlmock.NewRows(rowFieldsForCNSI).AddRow(adminEndpointArgs...) + + userEndpoint1Args := createEndpointRowArgs("CF Endpoint 2", "https://127.0.0.1:50002", mockAuthEndpoint, mockTokenEndpoint, mockEndpointAdmin1.ConnectedUser.GUID, mockEndpointAdmin1.ConnectedUser.Admin) + userEndpoint1Rows := sqlmock.NewRows(rowFieldsForCNSI).AddRow(userEndpoint1Args...) + + userEndpoint2Args := createEndpointRowArgs("CF Endpoint 3", "https://127.0.0.1:50003", mockAuthEndpoint, mockTokenEndpoint, mockEndpointAdmin2.ConnectedUser.GUID, mockEndpointAdmin2.ConnectedUser.Admin) + userEndpoint2Rows := sqlmock.NewRows(rowFieldsForCNSI).AddRow(userEndpoint2Args...) + + Convey("as admin", func() { + if errSession := pp.setSessionValues(ctx, mockAdmin.SessionValues); errSession != nil { + t.Error(errors.New("unable to mock/stub user in session object")) + } + Convey("edit admin endpoint", func() { + ctx.SetParamValues(fmt.Sprintf("%v", adminEndpointArgs[0])) + + mockStratosAuth. + EXPECT(). + GetUser(gomock.Eq(mockAdmin.ConnectedUser.GUID)). + Return(mockAdmin.ConnectedUser, nil) + + mock.ExpectQuery(selectAnyFromCNSIs).WithArgs(adminEndpointArgs[0]).WillReturnRows(adminEndpointRows) + + err := middleware(ctx) + dberr := mock.ExpectationsWereMet() + + Convey("request should be successful", func() { + So(err, ShouldBeNil) + So(dberr, ShouldBeNil) + }) + }) + Convey("edit user endpoint", func() { + ctx.SetParamValues(fmt.Sprintf("%v", userEndpoint1Args[0])) + + mockStratosAuth. + EXPECT(). + GetUser(gomock.Eq(mockAdmin.ConnectedUser.GUID)). + Return(mockAdmin.ConnectedUser, nil) + + mock.ExpectQuery(selectAnyFromCNSIs).WithArgs(userEndpoint1Args[0]).WillReturnRows(userEndpoint1Rows) + + err := middleware(ctx) + dberr := mock.ExpectationsWereMet() + + Convey("request should be successful", func() { + So(err, ShouldBeNil) + So(dberr, ShouldBeNil) + }) + }) + }) + Convey("as user", func() { + if errSession := pp.setSessionValues(ctx, mockEndpointAdmin1.SessionValues); errSession != nil { + t.Error(errors.New("unable to mock/stub user in session object")) + } + Convey("edit admin endpoint", func() { + ctx.SetParamValues(fmt.Sprintf("%v", adminEndpointArgs[0])) + + mockStratosAuth. + EXPECT(). + GetUser(gomock.Eq(mockEndpointAdmin1.ConnectedUser.GUID)). + Return(mockEndpointAdmin1.ConnectedUser, nil) + + mock.ExpectQuery(selectAnyFromCNSIs).WithArgs(adminEndpointArgs[0]).WillReturnRows(adminEndpointRows) + + err := middleware(ctx) + dberr := mock.ExpectationsWereMet() + + Convey("request should fail", func() { + So(err, + ShouldResemble, + handleSessionError(pp.Config, + ctx, + errors.New("Unauthorized"), + false, + "You must be Stratos admin to modify this endpoint.")) + So(dberr, ShouldBeNil) + }) + }) + Convey("edit own endpoint", func() { + ctx.SetParamValues(fmt.Sprintf("%v", userEndpoint1Args[0])) + + mockStratosAuth. + EXPECT(). + GetUser(gomock.Eq(mockEndpointAdmin1.ConnectedUser.GUID)). + Return(mockEndpointAdmin1.ConnectedUser, nil) + + mock.ExpectQuery(selectAnyFromCNSIs).WithArgs(userEndpoint1Args[0]).WillReturnRows(userEndpoint1Rows) + + err := middleware(ctx) + dberr := mock.ExpectationsWereMet() + + Convey("request should be successful", func() { + So(err, ShouldBeNil) + So(dberr, ShouldBeNil) + }) + }) + Convey("edit endpoint from different user", func() { + ctx.SetParamValues(fmt.Sprintf("%v", userEndpoint2Args[0])) + + mockStratosAuth. + EXPECT(). + GetUser(gomock.Eq(mockEndpointAdmin1.ConnectedUser.GUID)). + Return(mockEndpointAdmin1.ConnectedUser, nil) + + mock.ExpectQuery(selectAnyFromCNSIs).WithArgs(userEndpoint2Args[0]).WillReturnRows(userEndpoint2Rows) + + err := middleware(ctx) + dberr := mock.ExpectationsWereMet() + + Convey("request should fail", func() { + So(err, + ShouldResemble, + handleSessionError(pp.Config, + ctx, + errors.New("Unauthorized"), + false, + "EndpointAdmins are not allowed to modify endpoints created by other endpointAdmins.")) + So(dberr, ShouldBeNil) + }) + }) + }) + }) +} diff --git a/src/jetstream/mock_server_test.go b/src/jetstream/mock_server_test.go index 2dbd0441d2..93d1fe8b48 100644 --- a/src/jetstream/mock_server_test.go +++ b/src/jetstream/mock_server_test.go @@ -1,11 +1,15 @@ package main import ( + "crypto/sha1" "database/sql" + "database/sql/driver" + "encoding/base64" "fmt" "net/http" "net/http/httptest" "net/url" + "strconv" "strings" "testing" "time" @@ -39,6 +43,19 @@ type mockPGStore struct { StoredSession *sessions.Session } +type MockEndpointRequest struct { + HTTPTestServer *httptest.Server + EchoContext echo.Context + EndpointName string + InsertArgs []driver.Value + QueryArgs []driver.Value +} + +type MockUser struct { + ConnectedUser *interfaces.ConnectedUser + SessionValues map[string]interface{} +} + func (m *mockPGStore) New(r *http.Request, name string) (*sessions.Session, error) { session := &sessions.Session{ Values: make(map[interface{}]interface{}), @@ -176,18 +193,18 @@ func expectOneRow() sqlmock.Rows { func expectCFRow() sqlmock.Rows { return sqlmock.NewRows(rowFieldsForCNSI). - AddRow(mockCFGUID, "Some fancy CF Cluster", "cf", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, mockDopplerEndpoint, true, mockClientId, cipherClientSecret, true, "", "") + AddRow(mockCFGUID, "Some fancy CF Cluster", "cf", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, mockDopplerEndpoint, true, mockClientId, cipherClientSecret, true, "", "", "") } func expectCERow() sqlmock.Rows { return sqlmock.NewRows(rowFieldsForCNSI). - AddRow(mockCEGUID, "Some fancy HCE Cluster", "hce", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, "", true, mockClientId, cipherClientSecret, true, "", "") + AddRow(mockCEGUID, "Some fancy HCE Cluster", "hce", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, "", true, mockClientId, cipherClientSecret, true, "", "", "") } func expectCFAndCERows() sqlmock.Rows { return sqlmock.NewRows(rowFieldsForCNSI). - AddRow(mockCFGUID, "Some fancy CF Cluster", "cf", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, mockDopplerEndpoint, true, mockClientId, cipherClientSecret, false, "", ""). - AddRow(mockCEGUID, "Some fancy HCE Cluster", "hce", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, "", true, mockClientId, cipherClientSecret, false, "", "") + AddRow(mockCFGUID, "Some fancy CF Cluster", "cf", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, mockDopplerEndpoint, true, mockClientId, cipherClientSecret, false, "", "", ""). + AddRow(mockCEGUID, "Some fancy HCE Cluster", "hce", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, "", true, mockClientId, cipherClientSecret, false, "", "", "") } func expectTokenRow() sqlmock.Rows { @@ -202,6 +219,20 @@ func expectEncryptedTokenRow(mockEncryptionKey []byte) sqlmock.Rows { AddRow(mockTokenGUID, encryptedUaaToken, encryptedUaaToken, mockTokenExpiry, false, "OAuth2", "", mockUserGUID, nil) } +func createEndpointRowArgs(endpointName string, APIEndpoint string, authEndpoint string, tokenEndpoint string, uaaUserGUID string, userAdmin bool) []driver.Value { + creatorGUID := "" + + h := sha1.New() + if userAdmin { + h.Write([]byte(APIEndpoint)) + } else { + h.Write([]byte(APIEndpoint + uaaUserGUID)) + creatorGUID = uaaUserGUID + } + + return []driver.Value{base64.RawURLEncoding.EncodeToString(h.Sum(nil)), endpointName, "cf", APIEndpoint, authEndpoint, tokenEndpoint, mockDopplerEndpoint, true, mockClientId, cipherClientSecret, false, "", "", creatorGUID} +} + func setupHTTPTest(req *http.Request) (*httptest.ResponseRecorder, *echo.Echo, echo.Context, *portalProxy, *sql.DB, sqlmock.Sqlmock) { res := httptest.NewRecorder() e, ctx := setupEchoContext(res, req) @@ -214,6 +245,63 @@ func setupHTTPTest(req *http.Request) (*httptest.ResponseRecorder, *echo.Echo, e return res, e, ctx, pp, db, mock } +func setupPortalProxyWithAuthService(mockStratosAuth interfaces.StratosAuth) (*portalProxy, *sql.DB, sqlmock.Sqlmock) { + db, mock, dberr := sqlmock.New() + if dberr != nil { + fmt.Printf("an error '%s' was not expected when opening a stub database connection", dberr) + } + + pp := setupPortalProxy(db) + pp.StratosAuthService = mockStratosAuth + + return pp, db, mock +} + +func setupMockUser(guid string, admin bool, scopes []string) MockUser { + mockUser := MockUser{nil, nil} + mockUser.ConnectedUser = &interfaces.ConnectedUser{ + GUID: guid, + Admin: admin, + Scopes: scopes, + } + mockUser.SessionValues = make(map[string]interface{}) + mockUser.SessionValues["user_id"] = guid + mockUser.SessionValues["exp"] = time.Now().AddDate(0, 0, 1).Unix() + + return mockUser +} + +// mockV2Info needs to be closed +func setupMockEndpointRegisterRequest(t *testing.T, user *interfaces.ConnectedUser, mockV2Info *httptest.Server, endpointName string, createSystemEndpoint bool, generateAdminGUID bool) MockEndpointRequest { + + // create a request for each endpoint + req := setupMockReq("POST", "", map[string]string{ + "cnsi_name": endpointName, + "api_endpoint": mockV2Info.URL, + "skip_ssl_validation": "true", + "cnsi_client_id": mockClientId, + "cnsi_client_secret": mockClientSecret, + "create_system_endpoint": strconv.FormatBool(createSystemEndpoint), + }) + + res := httptest.NewRecorder() + _, ctx := setupEchoContext(res, req) + + uaaUserGUID := "" + + h := sha1.New() + if generateAdminGUID { + h.Write([]byte(mockV2Info.URL)) + } else { + h.Write([]byte(mockV2Info.URL + user.GUID)) + uaaUserGUID = user.GUID + } + insertArgs := []driver.Value{base64.RawURLEncoding.EncodeToString(h.Sum(nil)), endpointName, "cf", mockV2Info.URL, mockAuthEndpoint, mockTokenEndpoint, mockDopplerEndpoint, true, mockClientId, sqlmock.AnyArg(), false, "", "", uaaUserGUID} + queryArgs := []driver.Value{base64.RawURLEncoding.EncodeToString(h.Sum(nil)), endpointName, "cf", mockV2Info.URL, mockAuthEndpoint, mockTokenEndpoint, mockDopplerEndpoint, true, mockClientId, cipherClientSecret, false, "", "", uaaUserGUID} + + return MockEndpointRequest{mockV2Info, ctx, endpointName, insertArgs, queryArgs} +} + func msRoute(route string) mockServerFunc { return func(ms *mockServer) { ms.Route = route @@ -286,21 +374,25 @@ const ( stringCFType = "cf" - selectAnyFromTokens = `SELECT (.+) FROM tokens WHERE (.+)` - insertIntoTokens = `INSERT INTO tokens` - updateTokens = `UPDATE tokens` - selectAnyFromCNSIs = `SELECT (.+) FROM cnsis WHERE (.+)` - insertIntoCNSIs = `INSERT INTO cnsis` - findUserGUID = `SELECT user_guid FROM local_users WHERE (.+)` - addLocalUser = `INSERT INTO local_users (.+)` - findPasswordHash = `SELECT password_hash FROM local_users WHERE (.+)` - findUserScope = `SELECT user_scope FROM local_users WHERE (.+)` - updateLastLoginTime = `UPDATE local_users (.+)` - findLastLoginTime = `SELECT last_login FROM local_users WHERE (.+)` - getDbVersion = `SELECT version_id FROM goose_db_version WHERE is_applied = '1' ORDER BY id DESC LIMIT 1` + selectAnyFromTokens = `SELECT (.+) FROM tokens WHERE (.+)` + insertIntoTokens = `INSERT INTO tokens` + updateTokens = `UPDATE tokens` + deleteTokens = `DELETE FROM tokens WHERE (.+)` + selectFromCNSIs = `SELECT (.+) FROM cnsis` + selectAnyFromCNSIs = `SELECT (.+) FROM cnsis WHERE (.+)` + selectCreatorFromCNSIs = `SELECT (.+) FROM cnsis WHERE creator=(.+)` + deleteFromCNSIs = `DELETE FROM cnsis WHERE (.+)` + insertIntoCNSIs = `INSERT INTO cnsis` + findUserGUID = `SELECT user_guid FROM local_users WHERE (.+)` + addLocalUser = `INSERT INTO local_users (.+)` + findPasswordHash = `SELECT password_hash FROM local_users WHERE (.+)` + findUserScope = `SELECT user_scope FROM local_users WHERE (.+)` + updateLastLoginTime = `UPDATE local_users (.+)` + findLastLoginTime = `SELECT last_login FROM local_users WHERE (.+)` + getDbVersion = `SELECT version_id FROM goose_db_version WHERE is_applied = '1' ORDER BY id DESC LIMIT 1` ) -var rowFieldsForCNSI = []string{"guid", "name", "cnsi_type", "api_endpoint", "auth_endpoint", "token_endpoint", "doppler_logging_endpoint", "skip_ssl_validation", "client_id", "client_secret", "allow_sso", "sub_type", "meta_data"} +var rowFieldsForCNSI = []string{"guid", "name", "cnsi_type", "api_endpoint", "auth_endpoint", "token_endpoint", "doppler_logging_endpoint", "skip_ssl_validation", "client_id", "client_secret", "allow_sso", "sub_type", "meta_data", "creator"} var mockEncryptionKey = make([]byte, 32) diff --git a/src/jetstream/oauth_requests_test.go b/src/jetstream/oauth_requests_test.go index 76c8de6a1f..fe8e8c248a 100644 --- a/src/jetstream/oauth_requests_test.go +++ b/src/jetstream/oauth_requests_test.go @@ -107,8 +107,8 @@ func TestDoOauthFlowRequestWithValidToken(t *testing.T) { // p.GetCNSIRecord(r.GUID) -> cnsiRepo.Find(guid) - expectedCNSIRecordRow := sqlmock.NewRows([]string{"guid", "name", "cnsi_type", "api_endpoint", "auth_endpoint", "token_endpoint", "doppler_logging_endpoint", "skip_ssl_validation", "client_id", "client_secret", "allow_sso", "sub_type", "meta_data"}). - AddRow(mockCNSI.GUID, mockCNSI.Name, mockCNSI.CNSIType, mockURLasString, mockCNSI.AuthorizationEndpoint, mockCNSI.TokenEndpoint, mockCNSI.DopplerLoggingEndpoint, true, mockCNSI.ClientId, cipherClientSecret, true, "", "") + expectedCNSIRecordRow := sqlmock.NewRows([]string{"guid", "name", "cnsi_type", "api_endpoint", "auth_endpoint", "token_endpoint", "doppler_logging_endpoint", "skip_ssl_validation", "client_id", "client_secret", "allow_sso", "sub_type", "meta_data", "creator"}). + AddRow(mockCNSI.GUID, mockCNSI.Name, mockCNSI.CNSIType, mockURLasString, mockCNSI.AuthorizationEndpoint, mockCNSI.TokenEndpoint, mockCNSI.DopplerLoggingEndpoint, true, mockCNSI.ClientId, cipherClientSecret, true, "", "", "") mock.ExpectQuery(selectAnyFromCNSIs). WithArgs(mockCNSIGUID). WillReturnRows(expectedCNSIRecordRow) @@ -238,8 +238,8 @@ func TestDoOauthFlowRequestWithExpiredToken(t *testing.T) { WillReturnRows(expectedCNSITokenRow) // p.GetCNSIRecord(r.GUID) -> cnsiRepo.Find(guid) - expectedCNSIRecordRow := sqlmock.NewRows([]string{"guid", "name", "cnsi_type", "api_endpoint", "auth_endpoint", "token_endpoint", "doppler_logging_endpoint", "skip_ssl_validation", "client_id", "client_secret", "allow_sso", "sub_type", "meta_data"}). - AddRow(mockCNSI.GUID, mockCNSI.Name, mockCNSI.CNSIType, mockURLasString, mockCNSI.AuthorizationEndpoint, mockCNSI.TokenEndpoint, mockCNSI.DopplerLoggingEndpoint, true, mockCNSI.ClientId, cipherClientSecret, true, "", "") + expectedCNSIRecordRow := sqlmock.NewRows([]string{"guid", "name", "cnsi_type", "api_endpoint", "auth_endpoint", "token_endpoint", "doppler_logging_endpoint", "skip_ssl_validation", "client_id", "client_secret", "allow_sso", "sub_type", "meta_data", "creator"}). + AddRow(mockCNSI.GUID, mockCNSI.Name, mockCNSI.CNSIType, mockURLasString, mockCNSI.AuthorizationEndpoint, mockCNSI.TokenEndpoint, mockCNSI.DopplerLoggingEndpoint, true, mockCNSI.ClientId, cipherClientSecret, true, "", "", "") mock.ExpectQuery(selectAnyFromCNSIs). WithArgs(mockCNSIGUID). WillReturnRows(expectedCNSIRecordRow) diff --git a/src/jetstream/passthrough_test.go b/src/jetstream/passthrough_test.go index 5aa2f2e756..ddfaf48f8d 100644 --- a/src/jetstream/passthrough_test.go +++ b/src/jetstream/passthrough_test.go @@ -272,8 +272,8 @@ func TestValidateCNSIListWithValidGUID(t *testing.T) { _, _, _, pp, db, mock := setupHTTPTest(req) defer db.Close() - expectedCNSIRecordRow := sqlmock.NewRows([]string{"guid", "name", "cnsi_type", "api_endpoint", "auth_endpoint", "token_endpoint", "doppler_logging_endpoint", "skip_ssl_validation", "client_id", "client_secret", "allow_sso", "sub_type", "meta_data"}). - AddRow("valid-guid-abc123", "mock-name", "cf", "http://localhost", "http://localhost", "http://localhost", mockDopplerEndpoint, true, mockClientId, cipherClientSecret, true, "", "") + expectedCNSIRecordRow := sqlmock.NewRows([]string{"guid", "name", "cnsi_type", "api_endpoint", "auth_endpoint", "token_endpoint", "doppler_logging_endpoint", "skip_ssl_validation", "client_id", "client_secret", "allow_sso", "sub_type", "meta_data", ""}). + AddRow("valid-guid-abc123", "mock-name", "cf", "http://localhost", "http://localhost", "http://localhost", mockDopplerEndpoint, true, mockClientId, cipherClientSecret, true, "", "", "") mock.ExpectQuery(selectAnyFromCNSIs). WithArgs("valid-guid-abc123"). WillReturnRows(expectedCNSIRecordRow) diff --git a/src/jetstream/plugins/backup/main.go b/src/jetstream/plugins/backup/main.go index 72d23b4833..cf1032ea15 100644 --- a/src/jetstream/plugins/backup/main.go +++ b/src/jetstream/plugins/backup/main.go @@ -57,7 +57,7 @@ func (br *BackupRestore) AddSessionGroupRoutes(echoGroup *echo.Group) { func (br *BackupRestore) Init() error { enabledStr := br.portalProxy.Env().String("FEATURE_ALLOW_BACKUP", "true") if enabled, err := strconv.ParseBool(enabledStr); err == nil && !enabled { - return errors.New("Backup/restoure feature disabled via configuration") + return errors.New("Backup/restore feature disabled via configuration") } return nil diff --git a/src/jetstream/plugins/cloudfoundry/main.go b/src/jetstream/plugins/cloudfoundry/main.go index 10a8153904..8b78c9d780 100644 --- a/src/jetstream/plugins/cloudfoundry/main.go +++ b/src/jetstream/plugins/cloudfoundry/main.go @@ -109,6 +109,11 @@ func (c *CloudFoundrySpecification) cfLoginHook(context echo.Context) error { return nil } + userGUID, err := c.portalProxy.GetSessionStringValue(context, "user_id") + if err != nil { + return fmt.Errorf("Could not determine user_id from session: %s", err) + } + // CF auto reg cnsi entry missing, attempt to register if cfCnsi.CNSIType == "" { cfEndpointSpec, _ := c.portalProxy.GetEndpointTypeSpec("cf") @@ -122,7 +127,7 @@ func (c *CloudFoundrySpecification) cfLoginHook(context echo.Context) error { log.Infof("Auto-registering cloud foundry endpoint %s as \"%s\"", cfAPI, autoRegName) // Auto-register the Cloud Foundry - cfCnsi, err = c.portalProxy.DoRegisterEndpoint(autoRegName, cfAPI, true, c.portalProxy.GetConfig().CFClient, c.portalProxy.GetConfig().CFClientSecret, false, "", cfEndpointSpec.Info) + cfCnsi, err = c.portalProxy.DoRegisterEndpoint(autoRegName, cfAPI, true, c.portalProxy.GetConfig().CFClient, c.portalProxy.GetConfig().CFClientSecret, "", false, "", false, cfEndpointSpec.Info) if err != nil { log.Errorf("Could not auto-register Cloud Foundry endpoint: %v", err) return nil @@ -138,11 +143,6 @@ func (c *CloudFoundrySpecification) cfLoginHook(context echo.Context) error { log.Infof("Determining if user should auto-connect to %s.", cfAPI) - userGUID, err := c.portalProxy.GetSessionStringValue(context, "user_id") - if err != nil { - return fmt.Errorf("Could not determine user_id from session: %s", err) - } - cfTokenRecord, ok := c.portalProxy.GetCNSITokenRecordWithDisconnected(cfCnsi.GUID, userGUID) if ok && cfTokenRecord.Disconnected { // There exists a record but it's been cleared. This means user has disconnected manually. Don't auto-reconnect @@ -178,7 +178,7 @@ func (c *CloudFoundrySpecification) fetchAutoRegisterEndpoint() (string, interfa return "", interfaces.CNSIRecord{}, nil } // Error is populated if there was an error OR there was no record - cfCnsi, err := c.portalProxy.GetCNSIRecordByEndpoint(cfAPI) + cfCnsi, err := c.portalProxy.GetAdminCNSIRecordByEndpoint(cfAPI) return cfAPI, cfCnsi, err } diff --git a/src/jetstream/plugins/desktop/endpoints.go b/src/jetstream/plugins/desktop/endpoints.go index 5314f2acd5..b7feb96efa 100644 --- a/src/jetstream/plugins/desktop/endpoints.go +++ b/src/jetstream/plugins/desktop/endpoints.go @@ -41,6 +41,20 @@ func (d *DesktopEndpointStore) FindByAPIEndpoint(endpoint string, encryptionKey return d.store.FindByAPIEndpoint(endpoint, encryptionKey) } +func (d *DesktopEndpointStore) ListByAPIEndpoint(endpoint string, encryptionKey []byte) ([]*interfaces.CNSIRecord, error) { + local, err := ListCloudFoundry() + db, err := d.store.ListByAPIEndpoint(endpoint, encryptionKey) + merged := mergeEndpoints(db, local) + return merged, err +} + +func (d *DesktopEndpointStore) ListByCreator(userGUID string, encryptionKey []byte) ([]*interfaces.CNSIRecord, error) { + local, err := ListCloudFoundry() + db, err := d.store.ListByCreator(userGUID, encryptionKey) + merged := mergeEndpoints(db, local) + return merged, err +} + func (d *DesktopEndpointStore) Delete(guid string) error { if IsLocalCloudFoundry(guid) { updates := make(map[string]string) diff --git a/src/jetstream/plugins/desktop/kubernetes/endpoints.go b/src/jetstream/plugins/desktop/kubernetes/endpoints.go index 1539c8f43c..3994c77713 100644 --- a/src/jetstream/plugins/desktop/kubernetes/endpoints.go +++ b/src/jetstream/plugins/desktop/kubernetes/endpoints.go @@ -46,6 +46,20 @@ func (d *EndpointStore) FindByAPIEndpoint(endpoint string, encryptionKey []byte) return d.store.FindByAPIEndpoint(endpoint, encryptionKey) } +func (d *EndpointStore) ListByAPIEndpoint(endpoint string, encryptionKey []byte) ([]*interfaces.CNSIRecord, error) { + local, _, err := ListKubernetes() + db, err := d.store.ListByAPIEndpoint(endpoint, encryptionKey) + merged := mergeEndpoints(db, local) + return merged, err +} + +func (d *EndpointStore) ListByCreator(userGUID string, encryptionKey []byte) ([]*interfaces.CNSIRecord, error) { + local, _, err := ListKubernetes() + db, err := d.store.ListByCreator(userGUID, encryptionKey) + merged := mergeEndpoints(db, local) + return merged, err +} + func (d *EndpointStore) Delete(guid string) error { return d.store.Delete(guid) } diff --git a/src/jetstream/repository/cnsis/pgsql_cnsis.go b/src/jetstream/repository/cnsis/pgsql_cnsis.go index 03116f004c..ae8a4a05b2 100644 --- a/src/jetstream/repository/cnsis/pgsql_cnsis.go +++ b/src/jetstream/repository/cnsis/pgsql_cnsis.go @@ -12,23 +12,27 @@ import ( log "github.com/sirupsen/logrus" ) -var listCNSIs = `SELECT guid, name, cnsi_type, api_endpoint, auth_endpoint, token_endpoint, doppler_logging_endpoint, skip_ssl_validation, client_id, client_secret, sso_allowed, sub_type, meta_data +var listCNSIs = `SELECT guid, name, cnsi_type, api_endpoint, auth_endpoint, token_endpoint, doppler_logging_endpoint, skip_ssl_validation, client_id, client_secret, sso_allowed, sub_type, meta_data, creator FROM cnsis` -var listCNSIsByUser = `SELECT c.guid, c.name, c.cnsi_type, c.api_endpoint, c.doppler_logging_endpoint, t.user_guid, t.token_expiry, c.skip_ssl_validation, t.disconnected, t.meta_data, c.sub_type, c.meta_data as endpoint_metadata +var listCNSIsByUser = `SELECT c.guid, c.name, c.cnsi_type, c.api_endpoint, c.doppler_logging_endpoint, t.user_guid, t.token_expiry, c.skip_ssl_validation, t.disconnected, t.meta_data, c.sub_type, c.meta_data as endpoint_metadata, c.creator FROM cnsis c, tokens t WHERE c.guid = t.cnsi_guid AND t.token_type=$1 AND t.user_guid=$2 AND t.disconnected = '0'` -var findCNSI = `SELECT guid, name, cnsi_type, api_endpoint, auth_endpoint, token_endpoint, doppler_logging_endpoint, skip_ssl_validation, client_id, client_secret, sso_allowed, sub_type, meta_data +var listCNSIsByCreator = `SELECT guid, name, cnsi_type, api_endpoint, auth_endpoint, token_endpoint, doppler_logging_endpoint, skip_ssl_validation, client_id, client_secret, sso_allowed, sub_type, meta_data, creator + FROM cnsis + WHERE creator=$1` + +var findCNSI = `SELECT guid, name, cnsi_type, api_endpoint, auth_endpoint, token_endpoint, doppler_logging_endpoint, skip_ssl_validation, client_id, client_secret, sso_allowed, sub_type, meta_data, creator FROM cnsis WHERE guid=$1` -var findCNSIByAPIEndpoint = `SELECT guid, name, cnsi_type, api_endpoint, auth_endpoint, token_endpoint, doppler_logging_endpoint, skip_ssl_validation, client_id, client_secret, sso_allowed, sub_type, meta_data +var findCNSIByAPIEndpoint = `SELECT guid, name, cnsi_type, api_endpoint, auth_endpoint, token_endpoint, doppler_logging_endpoint, skip_ssl_validation, client_id, client_secret, sso_allowed, sub_type, meta_data, creator FROM cnsis WHERE api_endpoint=$1` -var saveCNSI = `INSERT INTO cnsis (guid, name, cnsi_type, api_endpoint, auth_endpoint, token_endpoint, doppler_logging_endpoint, skip_ssl_validation, client_id, client_secret, sso_allowed, sub_type, meta_data) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)` +var saveCNSI = `INSERT INTO cnsis (guid, name, cnsi_type, api_endpoint, auth_endpoint, token_endpoint, doppler_logging_endpoint, skip_ssl_validation, client_id, client_secret, sso_allowed, sub_type, meta_data, creator) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)` var deleteCNSI = `DELETE FROM cnsis WHERE guid = $1` @@ -55,6 +59,7 @@ func InitRepositoryProvider(databaseProvider string) { // Modify the database statements if needed, for the given database type listCNSIs = datastore.ModifySQLStatement(listCNSIs, databaseProvider) listCNSIsByUser = datastore.ModifySQLStatement(listCNSIsByUser, databaseProvider) + listCNSIsByCreator = datastore.ModifySQLStatement(listCNSIsByCreator, databaseProvider) findCNSI = datastore.ModifySQLStatement(findCNSI, databaseProvider) findCNSIByAPIEndpoint = datastore.ModifySQLStatement(findCNSIByAPIEndpoint, databaseProvider) saveCNSI = datastore.ModifySQLStatement(saveCNSI, databaseProvider) @@ -87,7 +92,7 @@ func (p *PostgresCNSIRepository) List(encryptionKey []byte) ([]*interfaces.CNSIR cnsi := new(interfaces.CNSIRecord) - err := rows.Scan(&cnsi.GUID, &cnsi.Name, &pCNSIType, &pURL, &cnsi.AuthorizationEndpoint, &cnsi.TokenEndpoint, &cnsi.DopplerLoggingEndpoint, &cnsi.SkipSSLValidation, &cnsi.ClientId, &cipherTextClientSecret, &cnsi.SSOAllowed, &subType, &metadata) + err := rows.Scan(&cnsi.GUID, &cnsi.Name, &pCNSIType, &pURL, &cnsi.AuthorizationEndpoint, &cnsi.TokenEndpoint, &cnsi.DopplerLoggingEndpoint, &cnsi.SkipSSLValidation, &cnsi.ClientId, &cipherTextClientSecret, &cnsi.SSOAllowed, &subType, &metadata, &cnsi.Creator) if err != nil { return nil, fmt.Errorf("Unable to scan CNSI records: %v", err) } @@ -124,8 +129,6 @@ func (p *PostgresCNSIRepository) List(encryptionKey []byte) ([]*interfaces.CNSIR return nil, fmt.Errorf("Unable to List CNSI records: %v", err) } - // rows.Close() - return cnsiList, nil } @@ -152,7 +155,7 @@ func (p *PostgresCNSIRepository) ListByUser(userGUID string) ([]*interfaces.Conn cluster := new(interfaces.ConnectedEndpoint) err := rows.Scan(&cluster.GUID, &cluster.Name, &pCNSIType, &pURL, &cluster.DopplerLoggingEndpoint, &cluster.Account, &cluster.TokenExpiry, &cluster.SkipSSLValidation, - &disconnected, &cluster.TokenMetadata, &subType, &metadata) + &disconnected, &cluster.TokenMetadata, &subType, &metadata, &cluster.Creator) if err != nil { return nil, fmt.Errorf("Unable to scan cluster records: %v", err) } @@ -183,6 +186,80 @@ func (p *PostgresCNSIRepository) ListByUser(userGUID string) ([]*interfaces.Conn return clusterList, nil } +// ListByCreator - Returns a list of CNSIs created by a user +func (p *PostgresCNSIRepository) ListByCreator(userGUID string, encryptionKey []byte) ([]*interfaces.CNSIRecord, error) { + log.Debug("ListByCreator") + return p.listBy(listCNSIsByCreator, userGUID, encryptionKey) +} + +// ListByAPIEndpoint - Returns a a list of CNSIs with the same APIEndpoint +func (p *PostgresCNSIRepository) ListByAPIEndpoint(endpoint string, encryptionKey []byte) ([]*interfaces.CNSIRecord, error) { + log.Debug("listByAPIEndpoint") + return p.listBy(findCNSIByAPIEndpoint, endpoint, encryptionKey) +} + +// listBy - Returns a list of CNSI Records found using the given query looking for match +func (p *PostgresCNSIRepository) listBy(query string, match string, encryptionKey []byte) ([]*interfaces.CNSIRecord, error) { + rows, err := p.db.Query(query, match) + if err != nil { + return nil, fmt.Errorf("Unable to retrieve CNSI records: %v", err) + } + defer rows.Close() + + var cnsiList []*interfaces.CNSIRecord + cnsiList = make([]*interfaces.CNSIRecord, 0) + + for rows.Next() { + var ( + pCNSIType string + pURL string + cipherTextClientSecret []byte + subType sql.NullString + metadata sql.NullString + ) + + cnsi := new(interfaces.CNSIRecord) + + err := rows.Scan(&cnsi.GUID, &cnsi.Name, &pCNSIType, &pURL, &cnsi.AuthorizationEndpoint, &cnsi.TokenEndpoint, &cnsi.DopplerLoggingEndpoint, &cnsi.SkipSSLValidation, &cnsi.ClientId, &cipherTextClientSecret, &cnsi.SSOAllowed, &subType, &metadata, &cnsi.Creator) + if err != nil { + return nil, fmt.Errorf("Unable to scan CNSI records: %v", err) + } + + cnsi.CNSIType = pCNSIType + + if cnsi.APIEndpoint, err = url.Parse(pURL); err != nil { + return nil, fmt.Errorf("Unable to parse API Endpoint: %v", err) + } + + if subType.Valid { + cnsi.SubType = subType.String + } + + if metadata.Valid { + cnsi.Metadata = metadata.String + } + + if len(cipherTextClientSecret) > 0 { + plaintextClientSecret, err := crypto.DecryptToken(encryptionKey, cipherTextClientSecret) + if err != nil { + return nil, err + } + cnsi.ClientSecret = plaintextClientSecret + } else { + // Empty secret means there was none, so set the plain text to an empty string + cnsi.ClientSecret = "" + } + + cnsiList = append(cnsiList, cnsi) + } + + if err = rows.Err(); err != nil { + return nil, fmt.Errorf("Unable to List CNSI records: %v", err) + } + + return cnsiList, nil +} + // Find - Returns a single CNSI Record func (p *PostgresCNSIRepository) Find(guid string, encryptionKey []byte) (interfaces.CNSIRecord, error) { log.Debug("Find") @@ -208,7 +285,7 @@ func (p *PostgresCNSIRepository) findBy(query, match string, encryptionKey []byt cnsi := new(interfaces.CNSIRecord) err := p.db.QueryRow(query, match).Scan(&cnsi.GUID, &cnsi.Name, &pCNSIType, &pURL, - &cnsi.AuthorizationEndpoint, &cnsi.TokenEndpoint, &cnsi.DopplerLoggingEndpoint, &cnsi.SkipSSLValidation, &cnsi.ClientId, &cipherTextClientSecret, &cnsi.SSOAllowed, &subType, &metadata) + &cnsi.AuthorizationEndpoint, &cnsi.TokenEndpoint, &cnsi.DopplerLoggingEndpoint, &cnsi.SkipSSLValidation, &cnsi.ClientId, &cipherTextClientSecret, &cnsi.SSOAllowed, &subType, &metadata, &cnsi.Creator) switch { case err == sql.ErrNoRows: @@ -256,7 +333,7 @@ func (p *PostgresCNSIRepository) Save(guid string, cnsi interfaces.CNSIRecord, e } if _, err := p.db.Exec(saveCNSI, guid, cnsi.Name, fmt.Sprintf("%s", cnsi.CNSIType), fmt.Sprintf("%s", cnsi.APIEndpoint), cnsi.AuthorizationEndpoint, cnsi.TokenEndpoint, cnsi.DopplerLoggingEndpoint, cnsi.SkipSSLValidation, - cnsi.ClientId, cipherTextClientSecret, cnsi.SSOAllowed, cnsi.SubType, cnsi.Metadata); err != nil { + cnsi.ClientId, cipherTextClientSecret, cnsi.SSOAllowed, cnsi.SubType, cnsi.Metadata, cnsi.Creator); err != nil { return fmt.Errorf("Unable to Save CNSI record: %v", err) } diff --git a/src/jetstream/repository/cnsis/pgsql_cnsis_test.go b/src/jetstream/repository/cnsis/pgsql_cnsis_test.go index 1d66395e19..68ed59ba86 100644 --- a/src/jetstream/repository/cnsis/pgsql_cnsis_test.go +++ b/src/jetstream/repository/cnsis/pgsql_cnsis_test.go @@ -32,7 +32,7 @@ func TestPgSQLCNSIs(t *testing.T) { insertIntoCNSIs = `INSERT INTO cnsis` deleteFromCNSIs = `DELETE FROM cnsis WHERE (.+)` rowFieldsForCNSI = []string{"guid", "name", "cnsi_type", "api_endpoint", "auth_endpoint", - "token_endpoint", "doppler_logging_endpoint", "skip_ssl_validation", "client_id", "client_secret", "sso_allowed", "sub_type", "meta_data"} + "token_endpoint", "doppler_logging_endpoint", "skip_ssl_validation", "client_id", "client_secret", "sso_allowed", "sub_type", "meta_data", "creator"} mockEncryptionKey = make([]byte, 32) ) cipherClientSecret, _ := crypto.EncryptToken(mockEncryptionKey, mockClientSecret) @@ -111,13 +111,13 @@ func TestPgSQLCNSIs(t *testing.T) { // general setup u, _ := url.Parse(mockAPIEndpoint) - r1 := &interfaces.CNSIRecord{GUID: mockCFGUID, Name: "Some fancy CF Cluster", CNSIType: "cf", APIEndpoint: u, AuthorizationEndpoint: mockAuthEndpoint, TokenEndpoint: mockAuthEndpoint, DopplerLoggingEndpoint: mockDopplerEndpoint, SkipSSLValidation: true, ClientId: mockClientId, ClientSecret: mockClientSecret, SSOAllowed: false} - r2 := &interfaces.CNSIRecord{GUID: mockCEGUID, Name: "Some fancy HCE Cluster", CNSIType: "hce", APIEndpoint: u, AuthorizationEndpoint: mockAuthEndpoint, TokenEndpoint: mockAuthEndpoint, DopplerLoggingEndpoint: "", SkipSSLValidation: true, ClientId: mockClientId, ClientSecret: mockClientSecret, SSOAllowed: false} + r1 := &interfaces.CNSIRecord{GUID: mockCFGUID, Name: "Some fancy CF Cluster", CNSIType: "cf", APIEndpoint: u, AuthorizationEndpoint: mockAuthEndpoint, TokenEndpoint: mockAuthEndpoint, DopplerLoggingEndpoint: mockDopplerEndpoint, SkipSSLValidation: true, ClientId: mockClientId, ClientSecret: mockClientSecret, SSOAllowed: false, Creator: ""} + r2 := &interfaces.CNSIRecord{GUID: mockCEGUID, Name: "Some fancy HCE Cluster", CNSIType: "hce", APIEndpoint: u, AuthorizationEndpoint: mockAuthEndpoint, TokenEndpoint: mockAuthEndpoint, DopplerLoggingEndpoint: "", SkipSSLValidation: true, ClientId: mockClientId, ClientSecret: mockClientSecret, SSOAllowed: false, Creator: ""} expectedList = append(expectedList, r1, r2) mockCFAndCERows = sqlmock.NewRows(rowFieldsForCNSI). - AddRow(mockCFGUID, "Some fancy CF Cluster", "cf", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, mockDopplerEndpoint, true, mockClientId, cipherClientSecret, false, "", ""). - AddRow(mockCEGUID, "Some fancy HCE Cluster", "hce", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, "", true, mockClientId, cipherClientSecret, false, "", "") + AddRow(mockCFGUID, "Some fancy CF Cluster", "cf", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, mockDopplerEndpoint, true, mockClientId, cipherClientSecret, false, "", "", ""). + AddRow(mockCEGUID, "Some fancy HCE Cluster", "hce", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, "", true, mockClientId, cipherClientSecret, false, "", "", "") mock.ExpectQuery(selectAnyFromCNSIs). WillReturnRows(mockCFAndCERows) @@ -182,7 +182,7 @@ func TestPgSQLCNSIs(t *testing.T) { var ( //SELECT c.guid, c.name, c.cnsi_type, c.api_endpoint, c.doppler_logging_endpoint, t.user_guid, t.token_expiry, c.skip_ssl_validation, t.disconnected, t.meta_data //rowFieldsForCluster = []string{"guid", "name", "cnsi_type", "api_endpoint", "account", "token_expiry", "skip_ssl_validation"} - rowFieldsForCluster = []string{"guid", "name", "cnsi_type", "api_endpoint", "doppler_logging_endpoint", "account", "token_expiry", "skip_ssl_validation", "disconnected", "meta_data", "sub_type", "endpoint_metadata"} + rowFieldsForCluster = []string{"guid", "name", "cnsi_type", "api_endpoint", "doppler_logging_endpoint", "account", "token_expiry", "skip_ssl_validation", "disconnected", "meta_data", "sub_type", "endpoint_metadata", "creator"} expectedList []*interfaces.ConnectedEndpoint mockAccount = "asd-gjfg-bob" ) @@ -239,13 +239,13 @@ func TestPgSQLCNSIs(t *testing.T) { // general setup u, _ := url.Parse(mockAPIEndpoint) - r1 := &interfaces.ConnectedEndpoint{GUID: mockCFGUID, Name: "Some fancy CF Cluster", CNSIType: "cf", APIEndpoint: u, DopplerLoggingEndpoint: mockDopplerEndpoint, Account: mockAccount, TokenExpiry: mockTokenExpiry, SkipSSLValidation: true} - r2 := &interfaces.ConnectedEndpoint{GUID: mockCEGUID, Name: "Some fancy HCE Cluster", CNSIType: "hce", APIEndpoint: u, DopplerLoggingEndpoint: mockDopplerEndpoint, Account: mockAccount, TokenExpiry: mockTokenExpiry, SkipSSLValidation: true} + r1 := &interfaces.ConnectedEndpoint{GUID: mockCFGUID, Name: "Some fancy CF Cluster", CNSIType: "cf", APIEndpoint: u, DopplerLoggingEndpoint: mockDopplerEndpoint, Account: mockAccount, TokenExpiry: mockTokenExpiry, SkipSSLValidation: true, Creator: ""} + r2 := &interfaces.ConnectedEndpoint{GUID: mockCEGUID, Name: "Some fancy HCE Cluster", CNSIType: "hce", APIEndpoint: u, DopplerLoggingEndpoint: mockDopplerEndpoint, Account: mockAccount, TokenExpiry: mockTokenExpiry, SkipSSLValidation: true, Creator: ""} expectedList = append(expectedList, r1, r2) mockClusterList = sqlmock.NewRows(rowFieldsForCluster). - AddRow(mockCFGUID, "Some fancy CF Cluster", "cf", mockAPIEndpoint, mockDopplerEndpoint, mockAccount, mockTokenExpiry, true, false, "", "", ""). - AddRow(mockCEGUID, "Some fancy HCE Cluster", "hce", mockAPIEndpoint, mockDopplerEndpoint, mockAccount, mockTokenExpiry, true, false, "", "", "") + AddRow(mockCFGUID, "Some fancy CF Cluster", "cf", mockAPIEndpoint, mockDopplerEndpoint, mockAccount, mockTokenExpiry, true, false, "", "", "", ""). + AddRow(mockCEGUID, "Some fancy HCE Cluster", "hce", mockAPIEndpoint, mockDopplerEndpoint, mockAccount, mockTokenExpiry, true, false, "", "", "", "") mock.ExpectQuery(selectFromCNSIandTokensWhere). WillReturnRows(mockClusterList) @@ -305,6 +305,255 @@ func TestPgSQLCNSIs(t *testing.T) { }) }) + Convey("Given a request for a list of CNSIs from a creator", t, func() { + + var ( + rowFieldsForCluster = []string{"guid", "name", "cnsi_type", "api_endpoint", "doppler_logging_endpoint", "account", "token_expiry", "skip_ssl_validation", "disconnected", "meta_data", "sub_type", "endpoint_metadata", "creator"} + expectedList []*interfaces.CNSIRecord + mockAccount = "asd-gjfg-bob" + ) + + // general setup + expectedList = make([]*interfaces.CNSIRecord, 0) + + db, mock, err := sqlmock.New() + if err != nil { + t.Errorf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + Convey("if no records exist in the database", func() { + + rs := sqlmock.NewRows(rowFieldsForCluster) + mock.ExpectQuery(selectFromCNSIsWhere). + WillReturnRows(rs) + + // Expectations + Convey("No CNSIs should be returned", func() { + repository, _ := NewPostgresCNSIRepository(db) + results, _ := repository.ListByCreator(mockAccount, mockEncryptionKey) + So(len(results), ShouldEqual, 0) + + dberr := mock.ExpectationsWereMet() + So(dberr, ShouldBeNil) + }) + + Convey("the list of returned CNSIs should be empty", func() { + repository, _ := NewPostgresCNSIRepository(db) + results, _ := repository.ListByCreator(mockAccount, mockEncryptionKey) + So(results, ShouldResemble, expectedList) + + dberr := mock.ExpectationsWereMet() + So(dberr, ShouldBeNil) + }) + + Convey("there should be no error returned", func() { + repository, _ := NewPostgresCNSIRepository(db) + _, err := repository.ListByCreator(mockAccount, mockEncryptionKey) + So(err, ShouldBeNil) + + dberr := mock.ExpectationsWereMet() + So(dberr, ShouldBeNil) + }) + }) + + Convey("if 2 records exist in the database", func() { + + var ( + mockClusterList sqlmock.Rows + ) + + // general setup + u, _ := url.Parse(mockAPIEndpoint) + r1 := &interfaces.CNSIRecord{GUID: mockCFGUID, Name: "Some fancy CF Cluster", CNSIType: "cf", APIEndpoint: u, AuthorizationEndpoint: mockAuthEndpoint, TokenEndpoint: mockAuthEndpoint, DopplerLoggingEndpoint: mockDopplerEndpoint, SkipSSLValidation: true, ClientId: mockClientId, ClientSecret: mockClientSecret, SSOAllowed: false, Creator: mockAccount} + r2 := &interfaces.CNSIRecord{GUID: mockCEGUID, Name: "Some fancy HCE Cluster", CNSIType: "hce", APIEndpoint: u, AuthorizationEndpoint: mockAuthEndpoint, TokenEndpoint: mockAuthEndpoint, DopplerLoggingEndpoint: "", SkipSSLValidation: true, ClientId: mockClientId, ClientSecret: mockClientSecret, SSOAllowed: false, Creator: mockAccount} + expectedList = append(expectedList, r1, r2) + + mockClusterList = sqlmock.NewRows(rowFieldsForCNSI). + AddRow(mockCFGUID, "Some fancy CF Cluster", "cf", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, mockDopplerEndpoint, true, mockClientId, cipherClientSecret, false, "", "", mockAccount). + AddRow(mockCEGUID, "Some fancy HCE Cluster", "hce", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, "", true, mockClientId, cipherClientSecret, false, "", "", mockAccount) + mock.ExpectQuery(selectFromCNSIsWhere). + WillReturnRows(mockClusterList) + + // Expectations + Convey("2 CNSIs should be returned", func() { + repository, _ := NewPostgresCNSIRepository(db) + results, _ := repository.ListByCreator(mockAccount, mockEncryptionKey) + So(len(results), ShouldEqual, 2) + + dberr := mock.ExpectationsWereMet() + So(dberr, ShouldBeNil) + }) + + Convey("the list returned should match the expected list", func() { + repository, _ := NewPostgresCNSIRepository(db) + results, _ := repository.ListByCreator(mockAccount, mockEncryptionKey) + So(results, ShouldResemble, expectedList) + + dberr := mock.ExpectationsWereMet() + So(dberr, ShouldBeNil) + }) + + Convey("there should be no error returned", func() { + repository, _ := NewPostgresCNSIRepository(db) + _, err := repository.ListByCreator(mockAccount, mockEncryptionKey) + So(err, ShouldBeNil) + + dberr := mock.ExpectationsWereMet() + So(dberr, ShouldBeNil) + }) + }) + + Convey("If the database call fails for some reason", func() { + + expectedErrorMessage := fmt.Sprintf("Unable to retrieve CNSI records: %s", unknownDBError) + + mock.ExpectQuery(selectAnyFromCNSIs). + WillReturnError(errors.New(unknownDBError)) + + Convey("the returned value should be nil", func() { + repository, _ := NewPostgresCNSIRepository(db) + results, _ := repository.ListByCreator(mockAccount, mockEncryptionKey) + So(results, ShouldBeNil) + + dberr := mock.ExpectationsWereMet() + So(dberr, ShouldBeNil) + }) + + Convey("there should be a 'not found' error returned", func() { + repository, _ := NewPostgresCNSIRepository(db) + _, err := repository.ListByCreator(mockAccount, mockEncryptionKey) + So(err, ShouldResemble, errors.New(expectedErrorMessage)) + + dberr := mock.ExpectationsWereMet() + So(dberr, ShouldBeNil) + }) + }) + }) + + Convey("Given a request for a list of CNSIs with a given APIEndpoint string", t, func() { + + var ( + rowFieldsForCluster = []string{"guid", "name", "cnsi_type", "api_endpoint", "doppler_logging_endpoint", "account", "token_expiry", "skip_ssl_validation", "disconnected", "meta_data", "sub_type", "endpoint_metadata", "creator"} + expectedList []*interfaces.CNSIRecord + ) + + // general setup + expectedList = make([]*interfaces.CNSIRecord, 0) + + db, mock, err := sqlmock.New() + if err != nil { + t.Errorf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + Convey("if no records exist in the database", func() { + + rs := sqlmock.NewRows(rowFieldsForCluster) + mock.ExpectQuery(selectFromCNSIsWhere). + WillReturnRows(rs) + + // Expectations + Convey("No CNSIs should be returned", func() { + repository, _ := NewPostgresCNSIRepository(db) + results, _ := repository.ListByAPIEndpoint(mockAPIEndpoint, mockEncryptionKey) + So(len(results), ShouldEqual, 0) + + dberr := mock.ExpectationsWereMet() + So(dberr, ShouldBeNil) + }) + + Convey("the list of returned CNSIs should be empty", func() { + repository, _ := NewPostgresCNSIRepository(db) + results, _ := repository.ListByAPIEndpoint(mockAPIEndpoint, mockEncryptionKey) + So(results, ShouldResemble, expectedList) + + dberr := mock.ExpectationsWereMet() + So(dberr, ShouldBeNil) + }) + + Convey("there should be no error returned", func() { + repository, _ := NewPostgresCNSIRepository(db) + _, err := repository.ListByAPIEndpoint(mockAPIEndpoint, mockEncryptionKey) + So(err, ShouldBeNil) + + dberr := mock.ExpectationsWereMet() + So(dberr, ShouldBeNil) + }) + }) + + Convey("if 2 records exist in the database", func() { + + var ( + mockClusterList sqlmock.Rows + ) + + // general setup + u, _ := url.Parse(mockAPIEndpoint) + r1 := &interfaces.CNSIRecord{GUID: mockCFGUID, Name: "Some fancy CF Cluster", CNSIType: "cf", APIEndpoint: u, AuthorizationEndpoint: mockAuthEndpoint, TokenEndpoint: mockAuthEndpoint, DopplerLoggingEndpoint: mockDopplerEndpoint, SkipSSLValidation: true, ClientId: mockClientId, ClientSecret: mockClientSecret, SSOAllowed: false, Creator: ""} + r2 := &interfaces.CNSIRecord{GUID: mockCEGUID, Name: "Some fancy HCE Cluster", CNSIType: "hce", APIEndpoint: u, AuthorizationEndpoint: mockAuthEndpoint, TokenEndpoint: mockAuthEndpoint, DopplerLoggingEndpoint: "", SkipSSLValidation: true, ClientId: mockClientId, ClientSecret: mockClientSecret, SSOAllowed: false, Creator: ""} + expectedList = append(expectedList, r1, r2) + + mockClusterList = sqlmock.NewRows(rowFieldsForCNSI). + AddRow(mockCFGUID, "Some fancy CF Cluster", "cf", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, mockDopplerEndpoint, true, mockClientId, cipherClientSecret, false, "", "", ""). + AddRow(mockCEGUID, "Some fancy HCE Cluster", "hce", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, "", true, mockClientId, cipherClientSecret, false, "", "", "") + mock.ExpectQuery(selectFromCNSIsWhere). + WillReturnRows(mockClusterList) + + // Expectations + Convey("2 CNSIs should be returned", func() { + repository, _ := NewPostgresCNSIRepository(db) + results, _ := repository.ListByAPIEndpoint(mockAPIEndpoint, mockEncryptionKey) + So(len(results), ShouldEqual, 2) + + dberr := mock.ExpectationsWereMet() + So(dberr, ShouldBeNil) + }) + + Convey("the list returned should match the expected list", func() { + repository, _ := NewPostgresCNSIRepository(db) + results, _ := repository.ListByAPIEndpoint(mockAPIEndpoint, mockEncryptionKey) + So(results, ShouldResemble, expectedList) + + dberr := mock.ExpectationsWereMet() + So(dberr, ShouldBeNil) + }) + + Convey("there should be no error returned", func() { + repository, _ := NewPostgresCNSIRepository(db) + _, err := repository.ListByAPIEndpoint(mockAPIEndpoint, mockEncryptionKey) + So(err, ShouldBeNil) + + dberr := mock.ExpectationsWereMet() + So(dberr, ShouldBeNil) + }) + }) + + Convey("If the database call fails for some reason", func() { + + expectedErrorMessage := fmt.Sprintf("Unable to retrieve CNSI records: %s", unknownDBError) + + mock.ExpectQuery(selectAnyFromCNSIs). + WillReturnError(errors.New(unknownDBError)) + + Convey("the returned value should be nil", func() { + repository, _ := NewPostgresCNSIRepository(db) + results, _ := repository.ListByAPIEndpoint(mockAPIEndpoint, mockEncryptionKey) + So(results, ShouldBeNil) + + dberr := mock.ExpectationsWereMet() + So(dberr, ShouldBeNil) + }) + + Convey("there should be a 'not found' error returned", func() { + repository, _ := NewPostgresCNSIRepository(db) + _, err := repository.ListByAPIEndpoint(mockAPIEndpoint, mockEncryptionKey) + So(err, ShouldResemble, errors.New(expectedErrorMessage)) + + dberr := mock.ExpectationsWereMet() + So(dberr, ShouldBeNil) + }) + }) + }) + Convey("Given a request to find a specific CNSI by GUID", t, func() { db, mock, err := sqlmock.New() @@ -317,10 +566,10 @@ func TestPgSQLCNSIs(t *testing.T) { // General setup u, _ := url.Parse(mockAPIEndpoint) - expectedCNSIRecord := interfaces.CNSIRecord{GUID: mockCFGUID, Name: "Some fancy CF Cluster", CNSIType: "cf", APIEndpoint: u, AuthorizationEndpoint: mockAuthEndpoint, TokenEndpoint: mockAuthEndpoint, DopplerLoggingEndpoint: mockDopplerEndpoint, SkipSSLValidation: true, ClientId: mockClientId, ClientSecret: mockClientSecret, SSOAllowed: false} + expectedCNSIRecord := interfaces.CNSIRecord{GUID: mockCFGUID, Name: "Some fancy CF Cluster", CNSIType: "cf", APIEndpoint: u, AuthorizationEndpoint: mockAuthEndpoint, TokenEndpoint: mockAuthEndpoint, DopplerLoggingEndpoint: mockDopplerEndpoint, SkipSSLValidation: true, ClientId: mockClientId, ClientSecret: mockClientSecret, SSOAllowed: false, Creator: ""} rs := sqlmock.NewRows(rowFieldsForCNSI). - AddRow(mockCFGUID, "Some fancy CF Cluster", "cf", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, mockDopplerEndpoint, true, mockClientId, cipherClientSecret, false, "", "") + AddRow(mockCFGUID, "Some fancy CF Cluster", "cf", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, mockDopplerEndpoint, true, mockClientId, cipherClientSecret, false, "", "", "") mock.ExpectQuery(selectFromCNSIsWhere). WillReturnRows(rs) @@ -414,10 +663,10 @@ func TestPgSQLCNSIs(t *testing.T) { // General setup u, _ := url.Parse(mockAPIEndpoint) - expectedCNSIRecord := interfaces.CNSIRecord{GUID: mockCFGUID, Name: "Some fancy CF Cluster", CNSIType: "cf", APIEndpoint: u, AuthorizationEndpoint: mockAuthEndpoint, TokenEndpoint: mockAuthEndpoint, DopplerLoggingEndpoint: mockDopplerEndpoint, SkipSSLValidation: true, ClientId: mockClientId, ClientSecret: mockClientSecret, SSOAllowed: true} + expectedCNSIRecord := interfaces.CNSIRecord{GUID: mockCFGUID, Name: "Some fancy CF Cluster", CNSIType: "cf", APIEndpoint: u, AuthorizationEndpoint: mockAuthEndpoint, TokenEndpoint: mockAuthEndpoint, DopplerLoggingEndpoint: mockDopplerEndpoint, SkipSSLValidation: true, ClientId: mockClientId, ClientSecret: mockClientSecret, SSOAllowed: true, Creator: ""} rs := sqlmock.NewRows(rowFieldsForCNSI). - AddRow(mockCFGUID, "Some fancy CF Cluster", "cf", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, mockDopplerEndpoint, true, mockClientId, cipherClientSecret, true, "", "") + AddRow(mockCFGUID, "Some fancy CF Cluster", "cf", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, mockDopplerEndpoint, true, mockClientId, cipherClientSecret, true, "", "", "") mock.ExpectQuery(selectFromCNSIsWhere). WillReturnRows(rs) @@ -512,10 +761,10 @@ func TestPgSQLCNSIs(t *testing.T) { // General setup u, _ := url.Parse(mockAPIEndpoint) - cnsi := interfaces.CNSIRecord{GUID: mockCFGUID, Name: "Some fancy CF Cluster", CNSIType: "cf", APIEndpoint: u, AuthorizationEndpoint: mockAuthEndpoint, TokenEndpoint: mockAuthEndpoint, DopplerLoggingEndpoint: mockDopplerEndpoint, SkipSSLValidation: true, ClientId: mockClientId, ClientSecret: mockClientSecret, SSOAllowed: true} + cnsi := interfaces.CNSIRecord{GUID: mockCFGUID, Name: "Some fancy CF Cluster", CNSIType: "cf", APIEndpoint: u, AuthorizationEndpoint: mockAuthEndpoint, TokenEndpoint: mockAuthEndpoint, DopplerLoggingEndpoint: mockDopplerEndpoint, SkipSSLValidation: true, ClientId: mockClientId, ClientSecret: mockClientSecret, SSOAllowed: true, Creator: ""} mock.ExpectExec(insertIntoCNSIs). - WithArgs(mockCFGUID, "Some fancy CF Cluster", "cf", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, mockDopplerEndpoint, true, mockClientId, sqlmock.AnyArg(), true, sqlmock.AnyArg(), sqlmock.AnyArg()). + WithArgs(mockCFGUID, "Some fancy CF Cluster", "cf", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, mockDopplerEndpoint, true, mockClientId, sqlmock.AnyArg(), true, sqlmock.AnyArg(), sqlmock.AnyArg(), ""). WillReturnResult(sqlmock.NewResult(1, 1)) Convey("there should be no error returned", func() { @@ -532,11 +781,11 @@ func TestPgSQLCNSIs(t *testing.T) { // General setup u, _ := url.Parse(mockAPIEndpoint) - cnsi := interfaces.CNSIRecord{GUID: mockCFGUID, Name: "Some fancy CF Cluster", CNSIType: "cf", APIEndpoint: u, AuthorizationEndpoint: mockAuthEndpoint, TokenEndpoint: mockAuthEndpoint, DopplerLoggingEndpoint: mockDopplerEndpoint, SkipSSLValidation: true, ClientId: mockClientId, ClientSecret: mockClientSecret, SSOAllowed: true} + cnsi := interfaces.CNSIRecord{GUID: mockCFGUID, Name: "Some fancy CF Cluster", CNSIType: "cf", APIEndpoint: u, AuthorizationEndpoint: mockAuthEndpoint, TokenEndpoint: mockAuthEndpoint, DopplerLoggingEndpoint: mockDopplerEndpoint, SkipSSLValidation: true, ClientId: mockClientId, ClientSecret: mockClientSecret, SSOAllowed: true, Creator: ""} expectedErrorMessage := fmt.Sprintf("Unable to Save CNSI record: %s", unknownDBError) mock.ExpectExec(insertIntoCNSIs). - WithArgs(mockCFGUID, "Some fancy CF Cluster", "cf", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, mockDopplerEndpoint, true, mockClientId, sqlmock.AnyArg(), true, sqlmock.AnyArg(), sqlmock.AnyArg()). + WithArgs(mockCFGUID, "Some fancy CF Cluster", "cf", mockAPIEndpoint, mockAuthEndpoint, mockAuthEndpoint, mockDopplerEndpoint, true, mockClientId, sqlmock.AnyArg(), true, sqlmock.AnyArg(), sqlmock.AnyArg(), ""). WillReturnError(errors.New(unknownDBError)) Convey("there should be an error returned", func() { diff --git a/src/jetstream/repository/interfaces/cnsis.go b/src/jetstream/repository/interfaces/cnsis.go index 089b3cbb21..d142e689d7 100644 --- a/src/jetstream/repository/interfaces/cnsis.go +++ b/src/jetstream/repository/interfaces/cnsis.go @@ -4,6 +4,8 @@ package interfaces type EndpointRepository interface { List(encryptionKey []byte) ([]*CNSIRecord, error) ListByUser(userGUID string) ([]*ConnectedEndpoint, error) + ListByCreator(userGUID string, encryptionKey []byte) ([]*CNSIRecord, error) + ListByAPIEndpoint(endpoint string, encryptionKey []byte) ([]*CNSIRecord, error) Find(guid string, encryptionKey []byte) (CNSIRecord, error) FindByAPIEndpoint(endpoint string, encryptionKey []byte) (CNSIRecord, error) Delete(guid string) error diff --git a/src/jetstream/repository/interfaces/config/config.go b/src/jetstream/repository/interfaces/config/config.go index a820481302..4d8fb46fb1 100644 --- a/src/jetstream/repository/interfaces/config/config.go +++ b/src/jetstream/repository/interfaces/config/config.go @@ -53,6 +53,39 @@ func parseAPIKeysConfigValue(input string) (APIKeysConfigValue, error) { return "", fmt.Errorf("Invalid value %q, allowed values: %q", input, allowedValues) } +// UserEndpointsConfigValue - special type for configuring whether user endpoints feature is enabled +type UserEndpointsConfigValue string + +// UserEndpointsConfigEnum - defines possible configuration values for Stratos user endpoints feature +var UserEndpointsConfigEnum = struct { + Disabled UserEndpointsConfigValue + AdminOnly UserEndpointsConfigValue + Enabled UserEndpointsConfigValue +}{ + Disabled: "disabled", + AdminOnly: "admin_only", + Enabled: "enabled", +} + +// verifies that given string is a valid config value +func parseUserEndpointsConfigValue(input string) (UserEndpointsConfigValue, error) { + t := reflect.TypeOf(UserEndpointsConfigEnum) + v := reflect.ValueOf(UserEndpointsConfigEnum) + + var allowedValues []string + + for i := 0; i < t.NumField(); i++ { + allowedValue := string(v.Field(i).Interface().(UserEndpointsConfigValue)) + if allowedValue == input { + return UserEndpointsConfigValue(input), nil + } + + allowedValues = append(allowedValues, allowedValue) + } + + return "", fmt.Errorf("Invalid value %q, allowed values: %q", input, allowedValues) +} + var urlType *url.URL // Load the given pointer to struct with values from the environment and the @@ -156,8 +189,11 @@ func SetStructFieldValue(value reflect.Value, field reflect.Value, val string) e newVal = b case reflect.String: apiKeysConfigType := reflect.TypeOf((*APIKeysConfigValue)(nil)).Elem() + userEndpointsConfigType := reflect.TypeOf((*UserEndpointsConfigValue)(nil)).Elem() if typ == apiKeysConfigType { newVal, err = parseAPIKeysConfigValue(val) + } else if typ == userEndpointsConfigType { + newVal, err = parseUserEndpointsConfigValue(val) } else { newVal = val } diff --git a/src/jetstream/repository/interfaces/portal_proxy.go b/src/jetstream/repository/interfaces/portal_proxy.go index e18731d5fc..dcc4e8f630 100644 --- a/src/jetstream/repository/interfaces/portal_proxy.go +++ b/src/jetstream/repository/interfaces/portal_proxy.go @@ -14,7 +14,7 @@ type PortalProxy interface { GetHttpClient(skipSSLValidation bool) http.Client GetHttpClientForRequest(req *http.Request, skipSSLValidation bool) http.Client RegisterEndpoint(c echo.Context, fetchInfo InfoFunc) error - DoRegisterEndpoint(cnsiName string, apiEndpoint string, skipSSLValidation bool, clientId string, clientSecret string, ssoAllowed bool, subType string, fetchInfo InfoFunc) (CNSIRecord, error) + DoRegisterEndpoint(cnsiName string, apiEndpoint string, skipSSLValidation bool, clientId string, clientSecret string, userId string, ssoAllowed bool, subType string, createSystemEndpoint bool, fetchInfo InfoFunc) (CNSIRecord, error) GetEndpointTypeSpec(typeName string) (EndpointPlugin, error) // Auth @@ -36,7 +36,7 @@ type PortalProxy interface { // Expose internal portal proxy records to extensions GetCNSIRecord(guid string) (CNSIRecord, error) - GetCNSIRecordByEndpoint(endpoint string) (CNSIRecord, error) + GetAdminCNSIRecordByEndpoint(endpoint string) (CNSIRecord, error) GetCNSITokenRecord(cnsiGUID string, userGUID string) (TokenRecord, bool) GetCNSITokenRecordWithDisconnected(cnsiGUID string, userGUID string) (TokenRecord, bool) GetCNSIUser(cnsiGUID string, userGUID string) (*ConnectedUser, bool) diff --git a/src/jetstream/repository/interfaces/structs.go b/src/jetstream/repository/interfaces/structs.go index 9077fed0d3..d057698ac7 100644 --- a/src/jetstream/repository/interfaces/structs.go +++ b/src/jetstream/repository/interfaces/structs.go @@ -55,6 +55,7 @@ type CNSIRecord struct { SubType string `json:"sub_type"` Metadata string `json:"metadata"` Local bool `json:"local"` + Creator string `json:"creator"` } // ConnectedEndpoint @@ -72,6 +73,7 @@ type ConnectedEndpoint struct { SubType string `json:"sub_type"` EndpointMetadata string `json:"metadata"` Local bool `json:"local"` + Creator string `json:"creator"` } const ( @@ -191,6 +193,13 @@ type ConnectedUser struct { Scopes []string `json:"scopes"` } +// CreatorInfo - additional information about the user who created an endpoint +type CreatorInfo struct { + Name string `json:"name"` + Admin bool `json:"admin"` + System bool `json:"system"` +} + type JWTUserTokenInfo struct { UserGUID string `json:"user_id"` UserName string `json:"user_name"` @@ -233,6 +242,7 @@ type Info struct { ListAllowLoadMaxed bool `json:"listAllowLoadMaxed,omitempty"` APIKeysEnabled string `json:"APIKeysEnabled"` HomeViewShowFavoritesOnly bool `json:"homeViewShowFavoritesOnly"` + UserEndpointsEnabled string `json:"userEndpointsEnabled"` } `json:"config"` } @@ -241,6 +251,7 @@ type EndpointDetail struct { *CNSIRecord EndpointMetadata interface{} `json:"endpoint_metadata,omitempty"` User *ConnectedUser `json:"user"` + Creator *CreatorInfo `json:"creator"` Metadata map[string]string `json:"metadata,omitempty"` TokenMetadata string `json:"-"` SystemSharedToken bool `json:"system_shared_token"` @@ -392,8 +403,9 @@ type PortalConfig struct { DatabaseProviderName string EnableTechPreview bool `configName:"ENABLE_TECH_PREVIEW"` CanMigrateDatabaseSchema bool - APIKeysEnabled config.APIKeysConfigValue `configName:"API_KEYS_ENABLED"` - HomeViewShowFavoritesOnly bool `configName:"HOME_VIEW_SHOW_FAVORITES_ONLY"` + APIKeysEnabled config.APIKeysConfigValue `configName:"API_KEYS_ENABLED"` + HomeViewShowFavoritesOnly bool `configName:"HOME_VIEW_SHOW_FAVORITES_ONLY"` + UserEndpointsEnabled config.UserEndpointsConfigValue `configName:"USER_ENDPOINTS_ENABLED"` // CanMigrateDatabaseSchema indicates if we can safely perform migrations // This depends on the deployment mechanism and the database config // e.g. if running in Cloud Foundry with a shared DB, then only the 0-index application instance @@ -414,14 +426,15 @@ type LoginToCNSIParams struct { } type RegisterEndpointParams struct { - EndpointType string `json:"endpoint_type" form:"endpoint_type" query:"endpoint_type"` - CNSIName string `json:"cnsi_name" form:"cnsi_name" query:"cnsi_name"` - APIEndpoint string `json:"api_endpoint" form:"api_endpoint" query:"api_endpoint"` - SkipSSLValidation string `json:"skip_ssl_validation" form:"skip_ssl_validation" query:"skip_ssl_validation"` - SSOAllowed string `json:"sso_allowed" form:"sso_allowed" query:"sso_allowed"` - CNSIClientID string `json:"cnsi_client_id" form:"cnsi_client_id" query:"cnsi_client_id"` - CNSIClientSecret string `json:"cnsi_client_secret" form:"cnsi_client_secret" query:"cnsi_client_secret"` - SubType string `json:"sub_type" form:"sub_type" query:"sub_type"` + EndpointType string `json:"endpoint_type" form:"endpoint_type" query:"endpoint_type"` + CNSIName string `json:"cnsi_name" form:"cnsi_name" query:"cnsi_name"` + APIEndpoint string `json:"api_endpoint" form:"api_endpoint" query:"api_endpoint"` + SkipSSLValidation string `json:"skip_ssl_validation" form:"skip_ssl_validation" query:"skip_ssl_validation"` + SSOAllowed string `json:"sso_allowed" form:"sso_allowed" query:"sso_allowed"` + CNSIClientID string `json:"cnsi_client_id" form:"cnsi_client_id" query:"cnsi_client_id"` + CNSIClientSecret string `json:"cnsi_client_secret" form:"cnsi_client_secret" query:"cnsi_client_secret"` + SubType string `json:"sub_type" form:"sub_type" query:"sub_type"` + CreateSystemEndpoint string `json:"create_system_endpoint" form:"create_system_endpoint" query:"create_system_endpoint"` } type UpdateEndpointParams struct { diff --git a/website/docs/advanced/user-endpoints.md b/website/docs/advanced/user-endpoints.md new file mode 100644 index 0000000000..2b08913049 --- /dev/null +++ b/website/docs/advanced/user-endpoints.md @@ -0,0 +1,43 @@ +--- +title: Configuring User Endpoints +sidebar_label: Configuring User Endpoints +--- + +Stratos provides a way for users to create endpoints without the need to be an administrator. + +> Note: Admin endpoint-ID's are generated through a SHA-1 encryption of the URL. Personal endpoints will differ in their ID, by using the URL + user-ID for encryption. This should pose no problem in the usual Stratos workflow, but if you depend on the ID to be based solely on the URL, then use this feature with caution. + +## Set up + +In order to enable User Endpoints support in Stratos: + +1. The environment variable `USER_ENDPOINTS_ENABLED` or helm chart value `console.userEndpointsEnabled` must be set +2. The UAA client used by Stratos needs an additional scope `stratos.endpointadmin` +3. Users need to have the `stratos.endpointadmin` group attached to them + +Once all steps have been completed, user within the `stratos.endpointadmin` group are allowed to create personal user endpoints. Endpoints created that way are only visible to their respective user and all admins. Admins will be able to create personal user endpoints after step 1 has been completed. + +## Environment variable + +`USER_ENDPOINTS_ENABLED` or helm chart value `console.userEndpointsEnabled` can be set to three different states: + +1. `disabled` (default) will disable this feature. Neither admins nor users will see user endpoints. +2. `admin_only` will hide user endpoints from users. Admins can create and see all user endpoints. +3. `enabled` will allow users within the `stratos.endpointadmin` group and admins to create personal user endpoints. These endpoints will only be visible to them or admins. + +## Adding scopes to the UAA client + +To add the scope to a client, modify the following [UAA CLI](https://github.com/cloudfoundry/cf-uaac) command: + +``` +uaac client update CLIENT_NAME --scope "OTHER_SCOPES stratos.endpointadmin" +``` + +Replace `CLIENT_NAME` with the used client and `OTHER_SCOPES` with the current configured scopes. + +To add the group and add users to it, use: + +``` +uaac group add stratos.endpointadmin +uaac member add stratos.endpointadmin USER_NAME +``` diff --git a/website/docs/deploy/kubernetes/install.md b/website/docs/deploy/kubernetes/install.md index fad7b188d4..1417667a44 100644 --- a/website/docs/deploy/kubernetes/install.md +++ b/website/docs/deploy/kubernetes/install.md @@ -79,6 +79,7 @@ The following table lists the configurable parameters of the Stratos Helm chart |console.templatesConfigMapName|Name of config map that provides the template files for user invitation emails|| |console.userInviteSubject|Email subject of the user invitation message|| |console.techPreview|Enable/disable Tech Preview features|false| +|console.userEndpointsEnabled|Enable/disable user endpoints or let only admins view and manage user endpoints (disabled, admin_only, enabled)|disabled| |console.ui.listMaxSize|Override the default maximum number of entities that a configured list can fetch. When a list meets this amount additional pages are not fetched|| |console.ui.listAllowLoadMaxed|If the maximum list size is met give the user the option to fetch all results|false| |console.localAdminPassword|Use local admin user instead of UAA - set to a password to enable|| diff --git a/website/sidebars.js b/website/sidebars.js index 9d38f14e6e..129d1680a2 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -45,6 +45,7 @@ module.exports = { ], 'Advanced Topics': [ 'advanced/sso', + 'advanced/user-endpoints' ], 'Develop': [ 'developer/contributing',