Merge remote-tracking branch 'upstream/master' into api-keys

This commit is contained in:
Neil MacDougall 2021-02-09 17:43:09 +00:00
commit 5a7963e5b0
35 changed files with 1107 additions and 142 deletions

View File

@ -291,7 +291,12 @@ authConfig:
enabled: '{provider} is currently enabled.'
testAndEnable: Test and Enable Authentication
authGroups:
actions:
refresh: Refresh Group Memberships
assignRoles: Assign Global Roles
assignEdit:
assignTitle: Assign Global Roles To Group
assignTo:
title: |-
@ -331,6 +336,10 @@ asyncButton:
action: 'Delete'
waiting: 'Deleting…'
success: 'Deleted'
remove:
action: 'Remove'
waiting: 'Removing…'
success: 'Removed'
continue:
action: 'Continue'
waiting: 'Saving…'
@ -1648,7 +1657,88 @@ rbac:
defaultLabel: Project Creator Default
noContext:
label: No Context
globalRoles:
types:
global:
label: Global Permissions
detail: |-
Controls what access the {isUser, select,
true {user}
false {group}} has to administer the overall {appName} installation.
custom:
label: Custom
detail: Roles not created by Rancher.
builtin:
label: Built-in
detail: Additional roles to define more fine-grain permissions model.
unknownRole:
detail: No description provided
role:
admin:
label: Administrator
detail: Administrators have full control over the entire installation and all resources in all clusters.
restricted-admin:
label: Restricted Administrator
detail: Restricted Admins have full control over all resources in all downstream clusters but no access to the local cluster.
user:
label: Standard User
detail: Standard Users can create new clusters and manage clusters and projects they have been granted access to.
user-base:
label: User-Base
detail: User-Base users have login-access only.
clusters-create:
label: Create new Clusters
detail: Allows the user to create new clusters and become the owner of them. Standard Users have this permission by default.
clustertemplates-create:
label: Create new RKE Cluster Templates
detail: Allows the user to create new RKE cluster templates and become the owner of them.
authn-manage:
label: Configure Authentication
detail: Allows the user to enable, configure, and disable all Authentication provider settings.
catalogs-manage:
label: Configure Catalogs
detail: Allows the user to add, edit, and remove Catalogs.
clusters-manage:
label: Manage all Clusters
detail: Allows the user to manage all clusters, including ones they are not a member of.
clusterscans-manage:
label: Manage CIS Cluster Scans
detail: Allows the user to launch new and manage CIS cluster scans.
kontainerdrivers-manage:
label: Create new Cluster Drivers
detail: Allows the user to create new cluster drivers and become the owner of them.
features-manage:
label: Configure Feature Flags
detail: Allows the user to enable and disable custom features via feature flag settings.
nodedrivers-manage:
label: Configure Node Drivers
detail: Allows the user to enable, configure, and remove all Node Driver settings.
nodetemplates-manage:
label: Manage Node Templates
detail: Allows the user to define, edit, and remove Node Templates.
podsecuritypolicytemplates-manage:
label: Manage Pod Security Policies (PSPs)
detail: Allows the user to define, edit, and remove PSPs.
roles-manage:
label: Manage Roles
detail: Allows the user to define, edit, and remove Role definitions.
settings-manage:
label: Manage Settings
detail: Allows the user to manage Rancher Settings.
users-manage:
label: Manage Users
detail: Allows the user to create, remove, and set passwords for all Users.
catalogs-use:
label: Use Catalogs
detail: Allows the user to see and deploy Templates from the Catalog. Standard Users have this permission by default.
nodetemplates-use:
label: Use Node Templates
detail: Allows the user to deploy new Nodes using any existing Node Templates.
view-rancher-metrics:
label: View Rancher Metrics
detail: Allows the user to view Metrics through the API.
base:
label: Login Access
resourceDetail:
detailTop:
@ -1830,6 +1920,7 @@ serviceTypes:
nodeport: Node Port
servicesPage:
anyNode: Any Node
labelsAnnotations:
label: Labels & Annotations
affinity:
@ -2931,6 +3022,11 @@ typeLabel:
one { RKE2 Cluster }
other { RKE2 Clusters }
}
group.principal: |-
{count, plural,
one { Group }
other { Groups }
}
action:
clone: Clone
@ -2948,6 +3044,7 @@ action:
show: Show
hide: Hide
copy: Copy
unassign: 'Unassign'
unit:
sec: secs

View File

@ -0,0 +1,221 @@
<script>
import { mapGetters } from 'vuex';
import { RBAC } from '@/config/types';
import Checkbox from '@/components/form/Checkbox';
import { _VIEW } from '@/config/query-params';
import Loading from '@/components/Loading';
export default {
components: {
Checkbox,
Loading,
},
props: {
mode: {
type: String,
default: _VIEW,
},
principalId: {
type: String,
default: ''
},
},
async fetch() {
try {
this.allRoles = await this.$store.dispatch('management/findAll', { type: RBAC.GLOBAL_ROLE });
if (!this.sortedRoles) {
this.sortedRoles = {
global: {},
builtin: {},
custom: {}
};
this.allRoles.forEach((role) => {
const type = this.getRoleType(role);
if (type) {
this.sortedRoles[type][role.id] = {
label: this.t(`rbac.globalRoles.role.${ role.id }.label`) || role.displayName,
description: this.t(`rbac.globalRoles.role.${ role.id }.detail`) || role.description || this.t(`rbac.globalRoles.unknownRole.detail`),
id: role.id,
role,
};
}
});
// Moving this out into the watch has issues....
this.globalRoleBindings = await this.$store.dispatch('management/findAll', { type: RBAC.GLOBAL_ROLE_BINDING });
this.update();
}
} catch (e) { }
},
data() {
return {
globalPermissions: [
'admin',
'restricted-admin',
'user',
'user-base',
],
user: null, // TODO: Populate in edit user mode
globalRoleBindings: null,
sortedRoles: null,
selectedRoles: [],
roleChanges: {}
};
},
computed: { ...mapGetters({ t: 'i18n/t' }) },
watch: {
principalId(principalId, oldPrincipalId) {
if (principalId === oldPrincipalId) {
return;
}
this.update();
}
},
methods: {
getRoleType(role) {
if (this.globalPermissions.find(p => p === role.id)) {
return 'global';
} else if (role.hidden) {
return null;
} else if (role.builtin) {
return 'builtin';
} else {
return 'custom';
}
},
getUnique(...ids) {
return `${ this.principalId }-${ ids.join('-') }`;
},
update() {
if (!this.principalId) {
return;
}
this.selectedRoles = [];
this.startingSelectedRoles = [];
const boundRoles = this.globalRoleBindings.filter(globalRoleBinding => globalRoleBinding.groupPrincipalName === this.principalId);
Object.entries(this.sortedRoles).forEach(([type, types]) => {
Object.entries(types).forEach(([roleId, mappedRole]) => {
const boundRole = boundRoles.find(boundRole => boundRole.globalRoleName === roleId);
if (!!boundRole) {
this.selectedRoles.push(roleId);
this.startingSelectedRoles.push({
roleId,
bindingId: boundRole.id
});
}
});
});
// TODO: If in create user mode... apply default selection using role.newUserDefault
// TODO: If in create/edit user mode... apply validation as per rancher/ui lib/global-admin/addon/components/form-global-roles/component.js
// Should validation come in via validationErrors property on model?
},
checkboxChanged() {
const addRoles = this.selectedRoles
.filter(selected => !this.startingSelectedRoles.find(startingRole => startingRole.roleId === selected));
const removeBindings = this.startingSelectedRoles
.filter(startingRole => !this.selectedRoles.find(selected => selected === startingRole.roleId))
.map(startingRole => startingRole.bindingId);
this.roleChanges = {
initialRoles: this.startingSelectedRoles,
addRoles,
removeBindings
};
this.$emit('changed', this.roleChanges);
},
async saveAddedRoles() {
const newBindings = await Promise.all(this.roleChanges.addRoles.map(role => this.$store.dispatch(`management/create`, {
type: RBAC.GLOBAL_ROLE_BINDING,
metadata: { generateName: `grb-` },
globalRoleName: role,
groupPrincipalName: this.principalId,
})));
await Promise.all(newBindings.map(newBinding => newBinding.save()));
},
async saveRemovedRoles() {
const existingBindings = await Promise.all(this.roleChanges.removeBindings.map(bindingId => this.$store.dispatch('management/find', {
type: RBAC.GLOBAL_ROLE_BINDING,
id: bindingId
})));
await Promise.all(existingBindings.map(existingBinding => existingBinding.remove()));
},
async save() {
// Ensure roles are added before removed (in case by removing one user is unable to add another)
await this.saveAddedRoles();
await this.saveRemovedRoles();
}
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<div v-else>
<form v-if="selectedRoles">
<div v-for="(sortedRole, type) in sortedRoles" :key="getUnique(type)" class="role-group mb-10">
<template v-if="Object.keys(sortedRole).length">
<h2>{{ t(`rbac.globalRoles.types.${type}.label`) }}</h2>
<div class="type-description mb-10">
{{ t(`rbac.globalRoles.types.${type}.detail`, { type: 'Application', isUser: !!user }) }}
</div>
<div class="checkbox-section" :class="'checkbox-section--' + type">
<div v-for="(role, roleId) in sortedRoles[type]" :key="getUnique(type, roleId)" class="checkbox mb-10 mr-10">
<Checkbox
:key="getUnique(type, roleId, 'checkbox')"
v-model="selectedRoles"
:value-when-true="roleId"
:label="role.label"
:mode="mode"
@input="checkboxChanged"
/>
<div class="description">
{{ role.description }}
</div>
</div>
</div>
</template>
</div>
</form>
</div>
</template>
<style lang='scss' scoped>
$detailSize: 11px;
.role-group {
.type-description {
font-size: $detailSize;
}
.checkbox-section {
display: grid;
grid-template-columns: repeat(3, 1fr);
&--global {
grid-template-columns: 100%;
}
.checkbox {
display: flex;
flex-direction: column;
.description {
font-size: $detailSize;
margin-top: 5px;
}
}
}
}
</style>

View File

@ -106,6 +106,12 @@ export default {
return this.t('promptRemove.protip', { alternateLabel });
},
deleteDisabled() {
const confirmFailed = this.needsConfirm && this.confirmName !== this.names[0];
return this.preventDelete || confirmFailed;
},
...mapState('action-menu', ['showPromptRemove', 'toRemove']),
...mapGetters({ t: 'i18n/t' })
},
@ -155,25 +161,20 @@ export default {
},
remove(btnCB) {
if (this.needsConfirm && this.confirmName !== this.names[0]) {
this.error = 'Resource names do not match';
btnCB(false);
// if doneLocation is defined, redirect after deleting
// if doneLocation is defined, redirect after deleting
let goTo;
if (this.doneLocation) {
// doneLocation will recompute to undefined when delete request completes
goTo = { ...this.doneLocation };
}
const serialRemove = this.toRemove.some(resource => resource.removeSerially);
if (serialRemove) {
this.serialRemove(goTo, btnCB);
} else {
let goTo;
if (this.doneLocation) {
// doneLocation will recompute to undefined when delete request completes
goTo = { ...this.doneLocation };
}
const serialRemove = this.toRemove.some(resource => resource.removeSerially);
if (serialRemove) {
this.serialRemove(goTo, btnCB);
} else {
this.parallelRemove(goTo, btnCB);
}
this.parallelRemove(goTo, btnCB);
}
},
@ -269,7 +270,7 @@ export default {
<button class="btn role-secondary" @click="close">
Cancel
</button>
<AsyncButton mode="delete" class="btn bg-error ml-10" :disabled="preventDelete" @click="remove" />
<AsyncButton mode="delete" class="btn bg-error ml-10" :disabled="deleteDisabled" @click="remove" />
</template>
</Card>
</modal>

View File

@ -22,6 +22,17 @@ export default {
default() {
return ['group'];
},
},
// either 'user' or 'group'
searchGroupTypes: {
type: String,
default: null,
},
retainSelection: {
type: Boolean,
default: false
}
},
@ -68,7 +79,9 @@ export default {
methods: {
add(id) {
this.$emit('add', id);
this.newValue = '';
if (!this.retainSelection) {
this.newValue = '';
}
},
onSearch(str, loading, vm) {
@ -97,7 +110,10 @@ export default {
type: NORMAN.PRINCIPAL,
actionName: 'search',
opt: { url: '/v3/principals?action=search' },
body: { name: str }
body: {
name: str,
principalType: this.searchGroupTypes
}
});
if ( this.searchStr === str ) {
@ -113,18 +129,18 @@ export default {
}
};
</script>
}
</script>
<template>
<LabeledSelect
v-model="newValue"
:mode="mode"
label="Add Member"
:label="retainSelection ? `Select Member` : `Add Member`"
placeholder="Start typing to search for principals"
:options="options"
:searchable="true"
:filterable="false"
class="select-principal"
:class="{'retain-selection': retainSelection}"
@input="add"
@search="onSearch"
>
@ -137,5 +153,22 @@ export default {
<template #option="option">
<Principal :key="option.label" :value="option.label" :use-muted="false" />
</template>
<template v-if="retainSelection" #selected-option="option">
<Principal :key="option.label" :value="option.label" :use-muted="false" class="mt-10 mb-10" />
</template>
</LabeledSelect>
</template>
<style lang="scss" scoped>
.select-principal {
&.retain-selection {
min-height: 84px;
&.focused {
.principal {
display: none;
}
}
}
}
</style>

View File

@ -1,11 +1,12 @@
<script>
import $ from 'jquery';
import { _EDIT } from '@/config/query-params';
import { _EDIT, _VIEW } from '@/config/query-params';
import { addObject, removeObject } from '@/utils/array';
export default {
props: {
value: {
type: Boolean,
type: [Boolean, Array],
default: false
},
@ -43,11 +44,19 @@ export default {
type: String,
default: null
},
valueWhenTrue: {
type: null,
default: true
},
},
computed: {
isDisabled() {
return (this.disabled || this.mode === 'view' );
return (this.disabled || this.mode === _VIEW );
},
isChecked() {
return this.isMulti() ? this.value.find(v => v === this.valueWhenTrue) : this.value === this.valueWhenTrue;
}
},
@ -61,9 +70,22 @@ export default {
click.ctrlKey = event.ctrlKey;
click.metaKey = event.metaKey;
this.$emit('input', !this.value);
$(this.$el).trigger(click);
// Flip the value
if (this.isMulti()) {
if (this.isChecked) {
removeObject(this.value, this.valueWhenTrue);
} else {
addObject(this.value, this.valueWhenTrue);
}
this.$emit('input', this.value);
} else {
this.$emit('input', !this.value);
$(this.$el).trigger(click);
}
}
},
isMulti() {
return Array.isArray(this.value);
}
}
};
@ -72,14 +94,15 @@ export default {
<template>
<label
class="checkbox-container"
:class="{disabled}"
:class="{ 'disabled': isDisabled}"
@keydown.enter.prevent="clicked($event)"
@keydown.space.prevent="clicked($event)"
@click.stop.prevent="clicked($event)"
>
<input
:checked="value"
:v-model="value"
v-model="value"
:checked="isChecked"
:value="valueWhenTrue"
type="checkbox"
:tabindex="-1"
@click.stop.prevent

View File

@ -1,4 +1,5 @@
<script>
import { NODE } from '@/config/types';
export default {
props: {
@ -17,49 +18,54 @@ export default {
},
computed: {
nodes() {
return this.$store.getters['cluster/all'](NODE);
},
// value may be JSON from "field.cattle.io/publicEndpoints" label
parsed() {
const nodes = this.nodes;
const nodeWithExternal = nodes.find(node => !!node.externalIp) || {};
const externalIp = nodeWithExternal.externalIp;
if ( this.value && this.value.length ) {
let out ;
try {
out = JSON.parse(this.value);
out.forEach((endpoint) => {
let protocol = 'http';
if (endpoint.port === 443) {
protocol = 'https';
}
if (endpoint.addresses) {
endpoint.link = `${ protocol }://${ endpoint.addresses[0] }:${ endpoint.port }`;
} else if (externalIp) {
endpoint.link = `${ protocol }://${ externalIp }:${ endpoint.port }`;
} else {
endpoint.display = `[${ this.t('servicesPage.anyNode') }]:${ endpoint.port }`;
}
});
return out;
} catch (err) {
return this.value[0];
}
return out;
}
return null;
},
bestLink() {
if (this.parsed && this.parsed.length ) {
if (this.parsed[0].addresses) {
let protocol = 'http';
if (this.parsed[0].port === 443) {
protocol = 'https';
}
return `${ protocol }://${ this.parsed[0].addresses[0] }:${ this.parsed[0].port }`;
}
return this.parsed;
} else {
return null;
}
},
protocol() {
const link = this.bestLink;
const { parsed } = this;
if ( link) {
if ( parsed) {
if (this.parsed[0].protocol) {
return this.parsed[0].protocol;
}
const match = link.match(/^([^:]+):\/\//);
const match = parsed.match(/^([^:]+):\/\//);
if ( match ) {
return match[1];
@ -76,11 +82,18 @@ export default {
<template>
<span>
<a v-if="bestLink" :href="bestLink" target="_blank" rel="nofollow noopener noreferrer">
<span v-if="parsed[0].port">{{ parsed[0].port }}/</span>{{ protocol }}
</a>
<span v-else class="text-muted">
&mdash;
</span>
<template v-for="endpoint in parsed">
<span v-if="endpoint.display" :key="endpoint.display" class="block">{{ endpoint.display }}</span>
<a
v-else
:key="endpoint.link"
class="block"
:href="endpoint.link"
target="_blank"
rel="nofollow noopener noreferrer"
>
<span v-if="endpoint.port">{{ endpoint.port }}/</span>{{ endpoint.protocol }}
</a>
</template>
</span>
</template>

View File

@ -1,7 +1,7 @@
<script>
import { isV4Format, isV6Format } from 'ip';
import CopyToClipboard from '@/components/CopyToClipboard';
import { mapGetters } from 'vuex';
export default {
components: { CopyToClipboard },
props: {
@ -13,7 +13,8 @@ export default {
computed: {
showBoth() {
return this.row.internalIp !== this.row.externalIp;
}
},
...mapGetters({ t: 'i18n/t' })
},
methods: {
isIp(ip) {
@ -29,7 +30,7 @@ export default {
{{ row.externalIp }} <CopyToClipboard label-as="tooltip" :text="row.externalIp" class="icon-btn" action-color="bg-transparent" />
</template>
<template v-else>
{{ t('internalExternalIp.none') }}
{{ t('generic.none') }}
</template>
<template v-if="showBoth">
@ -37,7 +38,7 @@ export default {
/ {{ row.internalIp }} <CopyToClipboard label-as="tooltip" :text="row.internalIp" class="icon-btn" action-color="bg-transparent" />
</template>
<template v-else>
{{ t('internalExternalIp.none') }}
{{ t('generic.none') }}
</template>
</template>
</span>

View File

@ -0,0 +1,19 @@
<script>
import PrincipalComponent from '@/components/auth/Principal';
export default {
components: { PrincipalComponent },
props: {
value: {
type: String,
default: ''
},
},
};
</script>
<template>
<div>
<PrincipalComponent :key="value" :value="value" :use-muted="false" />
</div>
</template>

View File

@ -0,0 +1,53 @@
<script>
import { NORMAN, RBAC } from '@/config/types';
export default {
props: {
value: {
type: String,
default: ''
},
},
computed: {
boundRoles() {
const principal = this.$store.getters['rancher/byId'](NORMAN.PRINCIPAL, this.value);
const globalRoleBindings = this.$store.getters['management/all'](RBAC.GLOBAL_ROLE_BINDING);
return globalRoleBindings
// Bindings for this group
.filter(globalRoleBinding => globalRoleBinding.groupPrincipalName === principal.id)
// Display name of role associated with binding
.map((binding) => {
const role = this.$store.getters['management/byId'](RBAC.GLOBAL_ROLE, binding.globalRoleName);
return {
detailLocation: role.detailLocation,
label: role ? role.displayName : role.id, // nameDisplay contains principal name, not required here
};
})
.sort((a, b) => a.label.localeCompare(b.label));
}
}
};
</script>
<template>
<div class="pgb">
<template v-for="(role, i) in boundRoles">
<nuxt-link :key="role.id" :to="role.detailLocation">
{{ role.label }}
</nuxt-link>
<template v-if="i + 1 < boundRoles.length">
,&nbsp;
</template>
</template>
</div>
</template>
<style lang='scss' scoped>
.pgb{
display: flex;
flex-wrap: wrap;
}
</style>

View File

@ -1,8 +1,8 @@
<script>
import isEmpty from 'lodash/isEmpty';
import { CATTLE_PUBLIC_ENDPOINTS } from '@/config/labels-annotations';
import has from 'lodash/has';
import Endpoints from '@/components/formatter/Endpoints';
import has from 'lodash/has';
export default {
components: { Endpoints },
@ -31,6 +31,7 @@ export default {
return false;
},
parsed() {
const { row, hasPublic } = this;
const {

View File

@ -1,26 +0,0 @@
<script>
export default {
props: {
value: {
type: String,
required: true
}
},
data() {
const endpoints = this.value.split(',');
return { endpoints };
}
};
</script>
<template>
<span>
<template v-for="endpoint in endpoints">
<span v-if="endpoint==='<none>'" :key="endpoint">{{ endpoint }}</span>
<a v-else :key="endpoint" rel="nofollow noopener noreferrer" target="_blank" :href="`//${endpoint}`">{{ endpoint }}</a>
<br :key="endpoint+'br'" />
</template>
</span>
</template>

View File

@ -1,6 +1,7 @@
import { DSL } from '@/store/type-map';
// import { STATE, NAME as NAME_COL, AGE } from '@/config/table-headers';
import { MANAGEMENT } from '@/config/types';
import { MANAGEMENT, NORMAN, RBAC } from '@/config/types';
import { GROUP_NAME, GROUP_ROLE_NAME } from '@/config/table-headers';
export const NAME = 'auth';
@ -8,11 +9,12 @@ export function init(store) {
const {
product,
basicType,
// weightType,
weightType,
configureType,
componentForType,
// headers,
// mapType,
headers,
mapType,
spoofedType,
virtualType,
} = DSL(store, NAME);
@ -21,7 +23,7 @@ export function init(store) {
inStore: 'management',
icon: 'user',
removable: false,
weight: -1,
weight: 50,
showClusterSwitcher: false,
});
@ -34,6 +36,64 @@ export function init(store) {
route: { name: 'c-cluster-auth-config' },
});
spoofedType({
label: store.getters['type-map/labelFor']({ id: NORMAN.SPOOFED.GROUP_PRINCIPAL }, 2),
type: NORMAN.SPOOFED.GROUP_PRINCIPAL,
collectionMethods: [],
schemas: [
{
id: NORMAN.SPOOFED.GROUP_PRINCIPAL,
type: 'schema',
collectionMethods: [],
resourceFields: {},
}
],
getInstances: async() => {
// Determine if the user can get fetch global roles & global role bindings. If not there's not much point in showing the table
const canFetchGlobalRoles = !!store.getters[`management/schemaFor`](RBAC.GLOBAL_ROLE);
const canFetchGlobalRoleBindings = !!store.getters[`management/schemaFor`](RBAC.GLOBAL_ROLE_BINDING);
if (!canFetchGlobalRoles || !canFetchGlobalRoleBindings) {
return [];
}
// Groups are a list of principals filtered via those that have group roles bound to them
const principals = await store.dispatch('rancher/findAll', {
type: NORMAN.PRINCIPAL,
opt: { url: '/v3/principals' }
});
const globalRoleBindings = await store.dispatch('management/findAll', {
type: RBAC.GLOBAL_ROLE_BINDING,
opt: { force: true }
});
// Up front fetch all global roles, instead of individually when needed (results in many duplicated requests)
await store.dispatch('management/findAll', { type: RBAC.GLOBAL_ROLE });
return principals
.filter(principal => principal.principalType === 'group' &&
!!globalRoleBindings.find(globalRoleBinding => globalRoleBinding.groupPrincipalName === principal.id)
)
.map(principal => ({
...principal,
type: NORMAN.SPOOFED.GROUP_PRINCIPAL
}));
}
});
configureType(NORMAN.SPOOFED.GROUP_PRINCIPAL, {
isCreatable: false,
showAge: false,
showState: false,
isRemovable: false,
showListMasthead: false,
});
// Use labelFor... so lookup succeeds with .'s in path.... and end result is 'trimmed' as per other entries
mapType(NORMAN.SPOOFED.GROUP_PRINCIPAL, store.getters['type-map/labelFor']({ id: NORMAN.SPOOFED.GROUP_PRINCIPAL }, 2));
weightType(NORMAN.SPOOFED.GROUP_PRINCIPAL, -1, true);
weightType(MANAGEMENT.USER, 100);
configureType(MANAGEMENT.USER, { showListMasthead: false });
configureType(MANAGEMENT.AUTH_CONFIG, {
@ -57,6 +117,11 @@ export function init(store) {
basicType([
'config',
MANAGEMENT.USER,
// MANAGEMENT.GROUP,
NORMAN.SPOOFED.GROUP_PRINCIPAL
]);
headers(NORMAN.SPOOFED.GROUP_PRINCIPAL, [
GROUP_NAME,
GROUP_ROLE_NAME
]);
}

View File

@ -1,3 +1,4 @@
import { CATTLE_PUBLIC_ENDPOINTS } from '@/config/labels-annotations';
import { NODE as NODE_TYPE } from '@/config/types';
// Note: 'id' is always the last sort, so you don't have to specify it here.
@ -606,10 +607,11 @@ export const WORKSPACE = {
export const WORKLOAD_IMAGES = { ...POD_IMAGES, value: '' };
export const WORKLOAD_ENDPOINTS = {
name: 'workloadEndpoints',
labelKey: 'tableHeaders.endpoints',
value: 'endpoints',
formatter: 'WorkloadEndpoints'
name: 'workloadEndpoints',
labelKey: 'tableHeaders.endpoints',
value: `$['metadata']['annotations']['${ CATTLE_PUBLIC_ENDPOINTS }']`,
formatter: 'Endpoints',
dashIfEmpty: true,
};
export const FLEET_SUMMARY = {
@ -713,6 +715,21 @@ export const CONFIGURED_RECEIVER = {
formatterOpts: { options: { internal: true } },
};
export const GROUP_NAME = {
name: 'group-name',
label: 'Group Name',
value: 'id',
sort: ['name'],
search: ['name'],
formatter: 'Principal',
width: 350
};
export const GROUP_ROLE_NAME = {
name: 'group-role-names',
label: 'Group Role Names',
value: 'id',
formatter: 'PrincipalGroupBindings',
export const ACCESS_KEY = {
name: 'name',
labelKey: 'tableHeaders.accessKey',

View File

@ -15,7 +15,9 @@ export const NORMAN = {
AUTH_CONFIG: 'authconfig',
PRINCIPAL: 'principal',
USER: 'user',
TOKEN: 'token',
TOKEN: 'token',
GROUP: 'group',
SPOOFED: { GROUP_PRINCIPAL: 'group.principal' }
};
// Public (via Norman)

View File

@ -293,7 +293,7 @@ export default {
<template #sub-row="{row, fullColspan}">
<tr>
<td :colspan="fullColspan">
<Banner v-if="row.remediation" class="sub-banner" :label="remediationDisplay(row)" color="warning" />
<Banner v-if="(row.state==='fail' || row.state==='warn')&& row.remediation" class="sub-banner" :label="remediationDisplay(row)" color="warning" />
<SortableTable
class="sub-table"
:rows="row.nodeRows"

View File

@ -155,12 +155,15 @@ export default {
@finish="save"
@cancel="done"
>
<template v-if="model.enabled && !isSaving">
<template v-if="model.enabled && !isEnabling && !editConfig">
<Banner color="success clearfix">
<div class="pull-left mt-10">
{{ t('authConfig.stateBanner.enabled', tArgs) }}
</div>
<div class="pull-right">
<button type="button" class="btn-sm role-primary" @click="goToEdit">
{{ t('action.edit') }}
</button>
<AsyncButton mode="disable" size="sm" action-color="bg-error" @click="disable" />
</div>
</Banner>

View File

@ -84,12 +84,15 @@ export default {
@finish="save"
@cancel="done"
>
<template v-if="model.enabled && !isEnabling">
<template v-if="model.enabled && !isEnabling && !editConfig">
<Banner color="success clearfix">
<div class="pull-left mt-10">
{{ t('authConfig.stateBanner.enabled', tArgs) }}
</div>
<div class="pull-right">
<button type="button" class="btn-sm role-primary" @click="goToEdit">
{{ t('action.edit') }}
</button>
<AsyncButton mode="disable" size="sm" action-color="bg-error" @click="disable" />
</div>
</Banner>

View File

@ -6,6 +6,9 @@ import UnitInput from '@/components/form/UnitInput';
import Banner from '@/components/Banner';
import FileSelector from '@/components/form/FileSelector';
const DEFAULT_NON_TLS_PORT = 389;
const DEFAULT_TLS_PORT = 636;
export default {
components: {
RadioGroup,
@ -59,6 +62,17 @@ export default {
if (neu) {
this.model.starttls = false;
}
const expectedCurrentDefault = neu ? DEFAULT_NON_TLS_PORT : DEFAULT_TLS_PORT;
const newDefault = neu ? DEFAULT_TLS_PORT : DEFAULT_NON_TLS_PORT;
// Note: The defualt port value is a number
// If the user edits this value, the type will be a string
// Thus, we will only change the value when the user toggles the TLS flag if they have
// NOT edited the port value in any way
if (this.model.port === expectedCurrentDefault) {
this.value.port = newDefault;
}
}
}

View File

@ -86,12 +86,15 @@ export default {
@finish="save"
@cancel="done"
>
<template v-if="model.enabled && !isEnabling">
<template v-if="model.enabled && !isEnabling && !editConfig">
<Banner color="success clearfix">
<div class="pull-left mt-10">
{{ t('authConfig.stateBanner.enabled', tArgs) }}
</div>
<div class="pull-right">
<button type="button" class="btn-sm role-primary" @click="goToEdit">
{{ t('action.edit') }}
</button>
<AsyncButton mode="disable" size="sm" action-color="bg-error" @click="disable" />
</div>
</Banner>

View File

@ -76,12 +76,15 @@ export default {
@finish="save"
@cancel="done"
>
<template v-if="model.enabled && !isEnabling">
<template v-if="model.enabled && !isEnabling && !editConfig">
<Banner color="success clearfix">
<div class="pull-left mt-10">
{{ t('authConfig.stateBanner.enabled', tArgs) }}
</div>
<div class="pull-right">
<button type="button" class="btn-sm role-primary" @click="goToEdit">
{{ t('action.edit') }}
</button>
<AsyncButton mode="disable" size="sm" action-color="bg-error" @click="disable" />
</div>
</Banner>
@ -138,12 +141,11 @@ export default {
</div>
</div>
<div class="row mb-20">
<div class="col span-6">
<div v-if="NAME !== 'okta'" class="col span-6">
<LabeledInput
v-model="model.entityID"
:label="t(`authConfig.saml.entityID`)"
:mode="mode"
required
/>
</div>
<div class="col span-6">

63
edit/group.principal.vue Normal file
View File

@ -0,0 +1,63 @@
<script>
import CreateEditView from '@/mixins/create-edit-view';
import GlobalRoleBindings from '@/components/GlobalRoleBindings.vue';
import CruResource from '@/components/CruResource';
import { exceptionToErrorsArray } from '@/utils/error';
import { NORMAN } from '@/config/types';
export default {
components: {
GlobalRoleBindings,
CruResource
},
mixins: [CreateEditView],
data() {
return {
errors: [],
valid: false,
};
},
methods: {
async save(buttonDone) {
this.errors = [];
try {
await this.$refs.grb.save();
await this.$store.dispatch('cluster/findAll', {
type: NORMAN.SPOOFED.GROUP_PRINCIPAL,
opt: { force: true }
}, { root: true }); // See PromptRemove.vue
this.$router.replace({ name: this.doneRoute });
buttonDone(true);
} catch (err) {
this.errors = exceptionToErrorsArray(err);
buttonDone(false);
}
},
changed(changes) {
this.valid = !!changes.addRoles.length || !!changes.removeBindings.length;
},
}
};
</script>
<template>
<div>
<CruResource
:done-route="doneRoute"
:mode="mode"
:resource="value"
:validation-passed="valid"
:errors="errors"
:can-yaml="false"
@finish="save"
>
<GlobalRoleBindings ref="grb" :principal-id="value.id" :mode="mode" @changed="changed" />
</CruResource>
</div>
</template>
<style lang="scss" scoped>
</style>

View File

@ -1,5 +1,5 @@
<script>
import { TYPES, DISPLAY_TYPES } from '@/models/secret';
import { TYPES } from '@/models/secret';
import { base64Encode, base64Decode } from '@/utils/crypto';
import { NAMESPACE } from '@/config/types';
import CreateEditView from '@/mixins/create-edit-view';
@ -112,9 +112,11 @@ export default {
const out = [];
this.types.forEach((type) => {
const displayType = this.typeDisplay(type);
const subtype = {
id: type,
label: DISPLAY_TYPES[type] || '',
label: displayType,
bannerAbbrv: this.initialDisplayFor(type)
};
@ -245,12 +247,18 @@ export default {
selectType(type) {
this.$set(this.value, '_type', type);
this.$emit('set-subtype', DISPLAY_TYPES[type]);
this.$emit('set-subtype', this.typeDisplay(type));
},
typeDisplay(type) {
const fallback = type.replace(/^kubernetes.io\//, '');
return this.$store.getters['i18n/withFallback'](`secret.types."${ type }"`, null, fallback);
},
// TODO icons for secret types?
initialDisplayFor(type) {
const typeDisplay = DISPLAY_TYPES[type];
const typeDisplay = this.typeDisplay(type);
return typeDisplay.split('').filter(letter => letter.match(/[A-Z]/)).join('');
},

105
list/group.principal.vue Normal file
View File

@ -0,0 +1,105 @@
<script>
import ResourceTable from '@/components/ResourceTable';
import Loading from '@/components/Loading';
import Masthead from '@/components/ResourceList/Masthead';
import { NORMAN } from '@/config/types';
import AsyncButton from '@/components/AsyncButton';
import { applyProducts } from '@/store/type-map';
import { NAME } from '@/config/product/auth';
import { MODE, _EDIT } from '@/config/query-params';
export default {
components: {
AsyncButton, ResourceTable, Masthead, Loading
},
props: {
resource: {
type: String,
required: true,
},
schema: {
type: Object,
required: true,
},
},
async fetch() {
this.rows = await this.$store.dispatch('cluster/findAll', { type: NORMAN.SPOOFED.GROUP_PRINCIPAL }, { root: true }); // See PromptRemove.vue
const principals = await this.$store.dispatch('rancher/findAll', { type: NORMAN.PRINCIPAL, opt: { url: '/v3/principals' } });
this.hasGroups = principals.filter(principal => principal.principalType === 'group')?.length;
this.canRefresh = await this.$store.dispatch('rancher/request', { url: '/v3/users?limit=0' })
.then(res => !!res?.actions?.refreshauthprovideraccess);
},
data() {
return {
rows: null,
hasGroups: false,
canRefresh: false,
assignLocation: {
path: `/c/local/${ NAME }/${ NORMAN.SPOOFED.GROUP_PRINCIPAL }/assign-edit`,
query: { [MODE]: _EDIT }
}
};
},
methods: {
async refreshGroupMemberships(buttonDone) {
try {
await this.$store.dispatch('rancher/request', {
url: '/v3/users?action=refreshauthprovideraccess',
method: 'post',
data: { },
});
// This is needed in SSR, but not SPA. If this is not here... when cluster/findAll is dispatched... we fail to find the spoofed
// type's `getInstance` fn as it hasn't been registered (`instanceMethods` in type-map file is empty)
await applyProducts(this.$store);
this.rows = await this.$store.dispatch('cluster/findAll', {
type: NORMAN.SPOOFED.GROUP_PRINCIPAL,
opt: { force: true }
}, { root: true });
buttonDone(true);
} catch (err) {
this.$store.dispatch('growl/fromError', { title: 'Error refreshing group memberships', err }, { root: true });
buttonDone(false);
}
},
},
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<div v-else>
<Masthead
:schema="schema"
:resource="resource"
>
<template slot="extraActions">
<AsyncButton
v-if="canRefresh"
mode="refresh"
:action-label="t('authGroups.actions.refresh')"
:waiting-label="t('authGroups.actions.refresh')"
:success-label="t('authGroups.actions.refresh')"
:error-label="t('authGroups.actions.refresh')"
@click="refreshGroupMemberships"
/>
<n-link
v-if="hasGroups"
:to="assignLocation"
class="btn role-primary"
>
{{ t("authGroups.actions.assignRoles") }}
</n-link>
</template>
</Masthead>
<ResourceTable :schema="schema" :rows="rows" />
</div>
</template>

43
list/service.vue Normal file
View File

@ -0,0 +1,43 @@
<script>
import ResourceTable from '@/components/ResourceTable';
import Loading from '@/components/Loading';
import { NODE } from '@/config/types';
import { allHash } from '@/utils/promise';
export default {
name: 'ListService',
components: { Loading, ResourceTable },
// fetch nodes before loading this page, as they may be referenced in the Target table column
async fetch() {
const store = this.$store;
const inStore = store.getters['currentProduct'].inStore;
let hasNodes = false;
try {
const schema = store.getters[`${ inStore }/schemaFor`](NODE);
if (schema) {
hasNodes = true;
}
} catch {}
const hash = { rows: store.dispatch(`${ inStore }/findAll`, { type: this.$attrs.resource }) };
if (hasNodes) {
hash.nodes = store.dispatch(`${ inStore }/findAll`, { type: NODE });
}
const res = await allHash(hash);
this.rows = res.rows;
},
data() {
return { rows: [] };
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<ResourceTable v-else :schema="$attrs.schema" :rows="rows" :headers="$attrs.headers" :group-by="$attrs.groupBy" />
</template>

View File

@ -1,6 +1,6 @@
<script>
import ResourceTable from '@/components/ResourceTable';
import { WORKLOAD_TYPES, SCHEMA, ENDPOINTS } from '@/config/types';
import { WORKLOAD_TYPES, SCHEMA, NODE } from '@/config/types';
import Loading from '@/components/Loading';
const schema = {
@ -18,7 +18,13 @@ export default {
components: { Loading, ResourceTable },
async fetch() {
this.$store.dispatch('cluster/findAll', { type: ENDPOINTS });
try {
const schema = this.$store.getters[`cluster/schemaFor`](NODE);
if (schema) {
this.$store.dispatch('cluster/findAll', { type: NODE });
}
} catch {}
let resources;

View File

@ -25,6 +25,11 @@ export default {
this.model.openLdapConfig = {};
this.showLdap = false;
}
if (this.value.configType === 'saml') {
if (!this.model.rancherApiHost || !this.model.rancherApiHost.length) {
this.$set(this.model, 'rancherApiHost', this.serverUrl);
}
}
},
data() {
@ -108,7 +113,7 @@ export default {
if (!this.model.accessMode) {
this.model.accessMode = 'unrestricted';
}
await this.model.doAction('testAndApply', obj);
await this.model.doAction('testAndApply', obj, { redirectUnauthorized: false });
}
// Reload principals to get the new ones from the provider
this.principals = await this.$store.dispatch('rancher/findAll', {

61
models/group.principal.js Normal file
View File

@ -0,0 +1,61 @@
import { NORMAN, RBAC } from '@/config/types';
import { clone } from '@/utils/object';
import principal from './principal';
export default {
...principal,
canViewInApi() {
return false;
},
nameDisplay() {
return this.principalNameDisplay;
},
principalNameDisplay() {
const principal = this.$rootGetters['rancher/byId'](NORMAN.PRINCIPAL, this.id);
return `${ principal.name } (${ principal.displayType })`;
},
detailLocation() {
const detailLocation = clone(this._detailLocation);
detailLocation.params.id = this.id; // Base fn removes part of the id (`github_team://3375666` --> `3375666`)
return detailLocation;
},
availableActions() {
return [
{
action: 'goToEdit',
label: this.t('action.edit'),
icon: 'icon icon-edit',
enabled: true,
},
{
action: 'unassignGroupRoles',
label: this.t('action.unassign'),
icon: 'icon icon-trash',
bulkable: true,
enabled: true,
bulkAction: 'unassignGroupRoles',
},
];
},
unassignGroupRoles() {
return (resources = this) => {
const principals = Array.isArray(resources) ? resources : [resources];
const globalRoleBindings = this.$rootGetters['management/all'](RBAC.GLOBAL_ROLE_BINDING)
.filter(globalRoleBinding => principals.find(principal => principal.id === globalRoleBinding.groupPrincipalName));
this.$dispatch('promptRemove', globalRoleBindings);
};
},
};

View File

@ -0,0 +1,21 @@
import { NORMAN, RBAC } from '@/config/types';
export default {
nameDisplay() {
const roleName = this.$getters['byId'](RBAC.GLOBAL_ROLE, this.globalRoleName);
const ownersName = this.groupPrincipalName ? this._displayPrincipal : this._displayUser;
return `${ roleName.displayName } (${ ownersName })` ;
},
_displayPrincipal() {
const principal = this.$rootGetters['rancher/byId'](NORMAN.PRINCIPAL, this.groupPrincipalName);
return `${ principal.name } - ${ principal.displayType }`;
},
_displayUser() {
return this.user;
}
};

View File

@ -1,7 +1,7 @@
import { insertAt } from '@/utils/array';
import { TARGET_WORKLOADS, TIMESTAMP, UI_MANAGED } from '@/config/labels-annotations';
import { WORKLOAD_TYPES, POD, ENDPOINTS, SERVICE } from '@/config/types';
import { get, set } from '@/utils/object';
import { WORKLOAD_TYPES, POD, SERVICE } from '@/config/types';
import { clone, get, set } from '@/utils/object';
import day from 'dayjs';
import { _CREATE } from '@/config/query-params';
@ -286,18 +286,6 @@ export default {
};
},
endpoints() {
const endpoints = this.$rootGetters['cluster/byId'](ENDPOINTS, this.id);
if (endpoints) {
const out = endpoints.metadata.fields[1];
return out;
}
},
// 30422
// create clusterip, nodeport, loadbalancer services from container port spec
servicesFromContainerPorts() {
return async(mode) => {
@ -391,10 +379,12 @@ export default {
case 'NodePort':
nodePort.spec.ports.push(portSpec);
break;
case 'LoadBalancer':
portSpec.port = port._lbPort;
loadBalancer.spec.ports.push(portSpec);
break;
case 'LoadBalancer': {
const lbPort = clone(portSpec);
lbPort.port = port._lbPort;
loadBalancer.spec.ports.push(lbPort);
break; }
default:
break;
}

View File

@ -127,12 +127,13 @@ export default {
}
});
if ( this.remember ) {
this.$cookies.set(USERNAME, this.username, {
this.$cookies.set(USERNAME, this.username,{
encode: x => x,
maxAge: 86400 * 365,
secure: true,
path: '/',
});
} else {
this.$cookies.remove(USERNAME);
}

View File

@ -60,9 +60,9 @@ export default {
window.close();
}
} else {
const { params, query } = this.$route;
const { query } = this.$route;
if ( window.opener && !get(params, 'login') && !get(params, 'errorCode') ) {
if ( window.opener ) {
const configQuery = get(query, 'config');
if ( samlProviders.includes(configQuery) ) {

View File

@ -10,7 +10,7 @@ export default {
AUTH_CONFIG() {
return MANAGEMENT.AUTH_CONFIG;
}
}
},
};
</script>

View File

@ -15,7 +15,8 @@ export default {
if ( enabled.length === 1 ) {
redirect({
name: 'c-cluster-auth-config-id',
params: { id: enabled[0].id }
params: { id: enabled[0].id },
query: { mode: _EDIT }
});
return { nonLocal };

View File

@ -0,0 +1,115 @@
<script>
import FooterComponent from '@/components/form/Footer';
import SelectPrincipal from '@/components/auth/SelectPrincipal.vue';
import GlobalRoleBindings from '@/components/GlobalRoleBindings.vue';
import { NORMAN } from '@/config/types';
import { _VIEW } from '@/config/query-params';
import { exceptionToErrorsArray } from '@/utils/error';
import { NAME } from '@/config/product/auth';
export default {
components: {
SelectPrincipal,
FooterComponent,
GlobalRoleBindings,
},
data() {
return {
errors: [],
principalId: null,
};
},
computed: {
mode() {
return !this.principalId ? _VIEW : this.$route.query.mode || _VIEW;
}
},
methods: {
setPrincipal(id) {
this.principalId = id;
return true;
},
async cancel() {
await this.return();
},
async save(buttonDone) {
this.errors = [];
try {
await this.$refs.grb.save();
await this.$store.dispatch('cluster/findAll', {
type: NORMAN.SPOOFED.GROUP_PRINCIPAL,
opt: { force: true }
}, { root: true }); // See PromptRemove.vue
this.$router.replace({
name: `c-cluster-product-resource`,
params: {
cluster: 'local',
product: NAME,
resource: NORMAN.SPOOFED.GROUP_PRINCIPAL,
},
});
buttonDone(true);
} catch (err) {
this.errors = exceptionToErrorsArray(err);
buttonDone(false);
}
},
}
};
</script>
<template>
<div>
<div>
<div class="masthead">
<header>
<div class="title">
<h1 class="m-0">
{{ t('authGroups.assignEdit.assignTitle') }}
</h1>
</div>
</header>
</div>
<form>
<SelectPrincipal :retain-selection="true" class="mb-20" :show-my-group-types="['group']" :search-group-types="'group'" @add="setPrincipal" />
<GlobalRoleBindings ref="grb" :principal-id="principalId" :mode="mode" />
<FooterComponent
:mode="mode"
:errors="errors"
@save="save"
@done="cancel"
>
</footercomponent>
</form>
</div>
</div>
</template>
<style lang="scss" scoped>
.masthead {
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
margin-bottom: 10px;
}
HEADER {
margin: 0;
}
.actions {
display: flex;
justify-content: flex-end;
align-items:center;
.btn {
margin-left: 10px;
}
}
</style>

View File

@ -13,7 +13,8 @@ export default {
// Spoofing is handled here to ensure it's done for both yaml and form editing.
// It became apparent that this was the only place that both intersected
if (opt.url.includes(SPOOFED_PREFIX) || opt.url.includes(SPOOFED_API_PREFIX)) {
const [empty, scheme, type, id] = opt.url.split('/'); // eslint-disable-line no-unused-vars
const [empty, scheme, type, ...rest] = opt.url.split('/'); // eslint-disable-line no-unused-vars
const id = rest.join('/'); // Cover case where id contains '/'
const isApi = scheme === SPOOFED_API_PREFIX;
const typemapGetter = id ? 'getSpoofedInstance' : 'getSpoofedInstances';
const schemas = await rootGetters['cluster/all'](SCHEMA);