+ {{#each this.tokenRecord.combinedPolicies as |policy|}}
+
{{policy.name}}
diff --git a/ui/mirage/config.js b/ui/mirage/config.js
index fac9d3761..20f2e6729 100644
--- a/ui/mirage/config.js
+++ b/ui/mirage/config.js
@@ -299,8 +299,16 @@ export default function () {
});
if (token) {
- const { policyIds } = token;
- const policies = server.db.policies.find(policyIds);
+ const policyIds = token.policyIds || [];
+
+ const roleIds = token.roleIds || [];
+ const roles = server.db.roles.find(roleIds);
+ const rolePolicyIds = roles.map((role) => role.policyIds).flat();
+
+ const policies = server.db.policies.find([
+ ...policyIds,
+ ...rolePolicyIds,
+ ]);
const hasReadPolicy = policies.find(
(p) =>
p.rulesJSON.Node?.Policy === 'read' ||
@@ -476,16 +484,59 @@ export default function () {
});
this.post('/acl/token', function (schema, request) {
- const { Name, Policies, Type } = JSON.parse(request.requestBody);
+ const { Name, Policies, Type, ExpirationTTL, ExpirationTime } = JSON.parse(
+ request.requestBody
+ );
+
+ function parseDuration(duration) {
+ const [_, value, unit] = duration.match(/(\d+)(\w)/);
+ const unitMap = {
+ s: 1000,
+ m: 1000 * 60,
+ h: 1000 * 60 * 60,
+ d: 1000 * 60 * 60 * 24,
+ };
+ return value * unitMap[unit];
+ }
+
+ // If there's an expirationTime, use that. Otherwise, use the TTL.
+ const expirationTime = ExpirationTime
+ ? new Date(ExpirationTime)
+ : ExpirationTTL
+ ? new Date(Date.now() + parseDuration(ExpirationTTL))
+ : null;
+
return server.create('token', {
name: Name,
policyIds: Policies,
type: Type,
id: faker.random.uuid(),
+ expirationTime,
createTime: new Date().toISOString(),
});
});
+ this.post('/acl/token/:id', function (schema, request) {
+ // If both Policies and Roles arrays are empty, return an error
+ const { Policies, Roles } = JSON.parse(request.requestBody);
+ if (!Policies.length && !Roles.length) {
+ return new Response(
+ 500,
+ {},
+ 'Either Policies or Roles must be specified'
+ );
+ }
+ return new Response(
+ 200,
+ {},
+ {
+ id: request.params.id,
+ Policies,
+ Roles,
+ }
+ );
+ });
+
this.get('/acl/token/self', function ({ tokens }, req) {
const secret = req.requestHeaders['X-Nomad-Token'];
const tokenForSecret = tokens.findBy({ secretId: secret });
@@ -557,7 +608,6 @@ export default function () {
const policy = policies.findBy({ name: req.params.id });
const secret = req.requestHeaders['X-Nomad-Token'];
const tokenForSecret = tokens.findBy({ secretId: secret });
-
if (req.params.id === 'anonymous') {
if (policy) {
return this.serialize(policy);
@@ -565,13 +615,15 @@ export default function () {
return new Response(404, {}, null);
}
}
-
// Return the policy only if the token that matches the request header
// includes the policy or if the token that matches the request header
// is of type management
if (
tokenForSecret &&
(tokenForSecret.policies.includes(policy) ||
+ tokenForSecret.roles.models.any((role) =>
+ role.policies.includes(policy)
+ ) ||
tokenForSecret.type === 'management')
) {
return this.serialize(policy);
@@ -581,21 +633,82 @@ export default function () {
return new Response(403, {}, null);
});
+ this.get('/acl/roles', function ({ roles }, req) {
+ return this.serialize(roles.all());
+ });
+
+ this.get('/acl/role/:id', function ({ roles }, req) {
+ const role = roles.findBy({ id: req.params.id });
+ return this.serialize(role);
+ });
+
+ this.post('/acl/role', function (schema, request) {
+ const { Name, Description } = JSON.parse(request.requestBody);
+ return server.create('role', {
+ name: Name,
+ description: Description,
+ });
+ });
+
+ this.put('/acl/role/:id', function (schema, request) {
+ const { Policies } = JSON.parse(request.requestBody);
+ if (!Policies.length) {
+ return new Response(500, {}, 'Policies must be specified');
+ }
+ return new Response(
+ 200,
+ {},
+ {
+ id: request.params.id,
+ Policies,
+ }
+ );
+ });
+
+ this.delete('/acl/role/:id', function (schema, request) {
+ const { id } = request.params;
+
+ // Also update any tokens whose policyIDs include this policy
+ const tokens =
+ server.schema.tokens.where((token) => token.roleIds?.includes(id)) || [];
+ tokens.models.forEach((token) => {
+ token.update({
+ roleIds: token.roleIds.filter((roleId) => roleId !== id),
+ });
+ });
+
+ server.db.roles.remove(id);
+ return '';
+ });
+
this.get('/acl/policies', function ({ policies }, req) {
return this.serialize(policies.all());
});
this.delete('/acl/policy/:id', function (schema, request) {
const { id } = request.params;
- schema.tokens
- .all()
- .models.filter((token) => token.policyIds.includes(id))
- .forEach((token) => {
- token.update({
- policyIds: token.policyIds.filter((pid) => pid !== id),
- });
+
+ // Also update any tokens whose policyIDs include this policy
+ const tokens =
+ server.schema.tokens.where((token) => token.policyIds?.includes(id)) ||
+ [];
+ tokens.models.forEach((token) => {
+ token.update({
+ policyIds: token.policyIds.filter((policyId) => policyId !== id),
});
+ });
+
+ // Also update any roles whose policyIDs include this policy
+ const roles =
+ server.schema.roles.where((role) => role.policyIds?.includes(id)) || [];
+ roles.models.forEach((role) => {
+ role.update({
+ policyIds: role.policyIds.filter((policyId) => policyId !== id),
+ });
+ });
+
server.db.policies.remove(id);
+
return '';
});
diff --git a/ui/mirage/factories/policy.js b/ui/mirage/factories/policy.js
index 39a44312c..bdb3ab924 100644
--- a/ui/mirage/factories/policy.js
+++ b/ui/mirage/factories/policy.js
@@ -7,11 +7,17 @@ import { Factory } from 'ember-cli-mirage';
import faker from 'nomad-ui/mirage/faker';
export default Factory.extend({
- id: () => faker.hacker.verb(),
+ // Extra randomness appended to not conflict with the otherwise-uniq'd policies generated
+ // in factories.token.afterCreate
+ id: () =>
+ `${faker.hacker.verb().replace(/\s/g, '-')}-${faker.random.alphaNumeric(
+ 5
+ )}`,
name() {
return this.id;
},
- description: () => (faker.random.number(10) >= 2 ? faker.lorem.sentence() : null),
+ description: () =>
+ faker.random.number(10) >= 2 ? faker.lorem.sentence() : null,
rules: `# Allow read only access to the default namespace
namespace "default" {
policy = "read"
diff --git a/ui/mirage/factories/token.js b/ui/mirage/factories/token.js
index a5d03fa1d..efbbee221 100644
--- a/ui/mirage/factories/token.js
+++ b/ui/mirage/factories/token.js
@@ -19,161 +19,166 @@ export default Factory.extend({
oneTimeSecret: () => faker.random.uuid(),
afterCreate(token, server) {
- if (token.policyIds && token.policyIds.length) return;
- const policyIds = Array(faker.random.number({ min: 1, max: 5 }))
- .fill(0)
- .map(() => faker.hacker.verb())
- .uniq();
+ // If the user has neither policies, nor roles with policies, add some fake ones.
+ if (
+ !(token.policyIds && token.policyIds.length) &&
+ !(token.roles && token.roles.models.map((r) => r.policies).flat().length)
+ ) {
+ const policyIds = Array(faker.random.number({ min: 1, max: 5 }))
+ .fill(0)
+ .map(() => faker.hacker.verb().replace(/\s/g, '-'))
+ .uniq();
- policyIds.forEach((policy) => {
- const dbPolicy = server.db.policies.find(policy);
- if (!dbPolicy) {
- server.create('policy', { id: policy });
- }
- });
-
- token.update({ policyIds });
-
- // Create a special policy with variables rules in place
- if (token.id === '53cur3-v4r14bl35') {
- const variableMakerPolicy = {
- id: 'Variable Maker',
- rules: `
-# Allow read only access to the default namespace
-namespace "*" {
- policy = "read"
- capabilities = ["list-jobs", "alloc-exec", "read-logs"]
- variables {
- # Base access is to all abilities for all variables
- path "*" {
- capabilities = ["list", "read", "destroy", "create"]
- }
- }
-}
-
-node {
- policy = "read"
-}
- `,
-
- rulesJSON: {
- Namespaces: [
- {
- Name: '*',
- Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'],
- Variables: {
- Paths: [
- {
- Capabilities: ['write', 'read', 'destroy', 'list'],
- PathSpec: '*',
- },
- ],
- },
- },
- ],
- },
- };
- server.create('policy', variableMakerPolicy);
- token.policyIds.push(variableMakerPolicy.id);
- }
- if (token.id === 'f3w3r-53cur3-v4r14bl35') {
- const variableViewerPolicy = {
- id: 'Variable Viewer',
- rules: `
-# Allow read only access to the default namespace
-namespace "*" {
- policy = "read"
- capabilities = ["list-jobs", "alloc-exec", "read-logs"]
- variables {
- # Base access is to all abilities for all variables
- path "*" {
- capabilities = ["list"]
- }
- }
-}
-
-namespace "namespace-1" {
- policy = "read"
- capabilities = ["list-jobs", "alloc-exec", "read-logs"]
- variables {
- # Base access is to all abilities for all variables
- path "*" {
- capabilities = ["list", "read", "destroy", "create"]
- }
- }
-}
-
-namespace "namespace-2" {
- policy = "read"
- capabilities = ["list-jobs", "alloc-exec", "read-logs"]
- variables {
- # Base access is to all abilities for all variables
- path "blue/*" {
- capabilities = ["list", "read", "destroy", "create"]
- }
- path "nomad/jobs/*" {
- capabilities = ["list", "read", "create"]
- }
- }
-}
-
-node {
- policy = "read"
-}
- `,
-
- rulesJSON: {
- Namespaces: [
- {
- Name: '*',
- Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'],
- Variables: {
- Paths: [
- {
- Capabilities: ['list'],
- PathSpec: '*',
- },
- ],
- },
- },
- {
- Name: 'namespace-1',
- Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'],
- Variables: {
- Paths: [
- {
- Capabilities: ['list', 'read', 'destroy', 'create'],
- PathSpec: '*',
- },
- ],
- },
- },
- {
- Name: 'namespace-2',
- Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'],
- Variables: {
- Paths: [
- {
- Capabilities: ['list', 'read', 'destroy', 'create'],
- PathSpec: 'blue/*',
- },
- {
- Capabilities: ['list', 'read', 'create'],
- PathSpec: 'nomad/jobs/*',
- },
- ],
- },
- },
- ],
- },
- };
- server.create('policy', variableViewerPolicy);
- token.policyIds.push(variableViewerPolicy.id);
- }
- if (token.id === '3XP1R35-1N-3L3V3N-M1NU735') {
- token.update({
- expirationTime: new Date(new Date().getTime() + 11 * 60 * 1000),
+ policyIds.forEach((policy) => {
+ const dbPolicy = server.db.policies.find(policy);
+ if (!dbPolicy) {
+ server.create('policy', { id: policy });
+ }
});
+
+ token.update({ policyIds });
+
+ // Create a special policy with variables rules in place
+ if (token.id === '53cur3-v4r14bl35') {
+ const variableMakerPolicy = {
+ id: 'Variable-Maker',
+ rules: `
+ # Allow read only access to the default namespace
+ namespace "*" {
+ policy = "read"
+ capabilities = ["list-jobs", "alloc-exec", "read-logs"]
+ variables {
+ # Base access is to all abilities for all variables
+ path "*" {
+ capabilities = ["list", "read", "destroy", "create"]
+ }
+ }
+ }
+
+ node {
+ policy = "read"
+ }
+ `,
+
+ rulesJSON: {
+ Namespaces: [
+ {
+ Name: '*',
+ Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'],
+ Variables: {
+ Paths: [
+ {
+ Capabilities: ['write', 'read', 'destroy', 'list'],
+ PathSpec: '*',
+ },
+ ],
+ },
+ },
+ ],
+ },
+ };
+ server.create('policy', variableMakerPolicy);
+ token.policyIds.push(variableMakerPolicy.id);
+ }
+ if (token.id === 'f3w3r-53cur3-v4r14bl35') {
+ const variableViewerPolicy = {
+ id: 'Variable-Viewer',
+ rules: `
+ # Allow read only access to the default namespace
+ namespace "*" {
+ policy = "read"
+ capabilities = ["list-jobs", "alloc-exec", "read-logs"]
+ variables {
+ # Base access is to all abilities for all variables
+ path "*" {
+ capabilities = ["list"]
+ }
+ }
+ }
+
+ namespace "namespace-1" {
+ policy = "read"
+ capabilities = ["list-jobs", "alloc-exec", "read-logs"]
+ variables {
+ # Base access is to all abilities for all variables
+ path "*" {
+ capabilities = ["list", "read", "destroy", "create"]
+ }
+ }
+ }
+
+ namespace "namespace-2" {
+ policy = "read"
+ capabilities = ["list-jobs", "alloc-exec", "read-logs"]
+ variables {
+ # Base access is to all abilities for all variables
+ path "blue/*" {
+ capabilities = ["list", "read", "destroy", "create"]
+ }
+ path "nomad/jobs/*" {
+ capabilities = ["list", "read", "create"]
+ }
+ }
+ }
+
+ node {
+ policy = "read"
+ }
+ `,
+
+ rulesJSON: {
+ Namespaces: [
+ {
+ Name: '*',
+ Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'],
+ Variables: {
+ Paths: [
+ {
+ Capabilities: ['list'],
+ PathSpec: '*',
+ },
+ ],
+ },
+ },
+ {
+ Name: 'namespace-1',
+ Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'],
+ Variables: {
+ Paths: [
+ {
+ Capabilities: ['list', 'read', 'destroy', 'create'],
+ PathSpec: '*',
+ },
+ ],
+ },
+ },
+ {
+ Name: 'namespace-2',
+ Capabilities: ['list-jobs', 'alloc-exec', 'read-logs'],
+ Variables: {
+ Paths: [
+ {
+ Capabilities: ['list', 'read', 'destroy', 'create'],
+ PathSpec: 'blue/*',
+ },
+ {
+ Capabilities: ['list', 'read', 'create'],
+ PathSpec: 'nomad/jobs/*',
+ },
+ ],
+ },
+ },
+ ],
+ },
+ };
+ server.create('policy', variableViewerPolicy);
+ token.policyIds.push(variableViewerPolicy.id);
+ }
+ if (token.id === '3XP1R35-1N-3L3V3N-M1NU735') {
+ token.update({
+ expirationTime: new Date(new Date().getTime() + 11 * 60 * 1000),
+ });
+ }
}
},
});
diff --git a/ui/mirage/models/token.js b/ui/mirage/models/token.js
new file mode 100644
index 000000000..c3ea6d51d
--- /dev/null
+++ b/ui/mirage/models/token.js
@@ -0,0 +1,11 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { Model, belongsTo, hasMany } from 'ember-cli-mirage';
+
+export default Model.extend({
+ policies: hasMany(),
+ roles: hasMany(),
+});
diff --git a/ui/mirage/scenarios/default.js b/ui/mirage/scenarios/default.js
index 58c0ed14b..b9316ceed 100644
--- a/ui/mirage/scenarios/default.js
+++ b/ui/mirage/scenarios/default.js
@@ -26,6 +26,7 @@ export const allScenarios = {
variableTestCluster,
servicesTestCluster,
policiesTestCluster,
+ rolesTestCluster,
...topoScenarios,
...sysbatchScenarios,
};
@@ -96,6 +97,102 @@ function smallCluster(server) {
activeDeployment: true,
});
+ server.create('policy', {
+ id: 'client-reader',
+ name: 'client-reader',
+ description: "Can read nodes and that's about it",
+ rulesJSON: {
+ Node: {
+ Policy: 'read',
+ },
+ },
+ rules: `# Allow node read access`,
+ });
+
+ server.create('policy', {
+ id: 'client-writer',
+ name: 'client-writer',
+ description: 'Can write to nodes',
+ rulesJSON: {
+ Node: {
+ Policy: 'write',
+ },
+ },
+ rules: `# Allow node write access`,
+ });
+
+ server.create('policy', {
+ id: 'job-reader',
+ name: 'job-reader',
+ description: "Can read jobs and that's about it",
+ rulesJSON: {
+ namespace: {
+ '*': {
+ policy: 'read',
+ },
+ },
+ },
+ rules: `# Job read access`,
+ });
+
+ server.create('policy', {
+ id: 'job-writer',
+ name: 'job-writer',
+ description: 'Can write jobs',
+ rulesJSON: {
+ Namespaces: [
+ {
+ Name: '*',
+ Policy: '',
+ Capabilities: ['submit-job'],
+ Variables: null,
+ },
+ ],
+ },
+ rules: `# Job write access`,
+ });
+
+ server.create('policy', {
+ id: 'variable-lister',
+ name: 'variable-lister',
+ description: 'Can list variables',
+ rulesJSON: {
+ namespace: {
+ '*': {
+ variables: {
+ path: {
+ capabilities: ['list'],
+ pathspec: '*',
+ },
+ },
+ },
+ },
+ },
+ rules: `# Variable list access`,
+ });
+
+ server.create('role', {
+ id: 'operator',
+ name: 'operator',
+ description: 'Can operate',
+ policyIds: ['client-reader', 'client-writer', 'job-reader', 'job-writer'],
+ });
+
+ server.create('role', {
+ id: 'sysadmin',
+ name: 'sysadmin',
+ description: 'Can modify nodes',
+ policyIds: ['client-reader', 'client-writer'],
+ });
+
+ server.create('token', {
+ type: 'client',
+ name: 'Tiarna Riarthóir',
+ id: 'administrator-token',
+ roleIds: ['operator', 'sysadmin'],
+ policyIds: ['variable-lister'],
+ });
+
//#region Active Deployment
const activelyDeployingJobGroups = 2;
@@ -439,6 +536,218 @@ function policiesTestCluster(server) {
server.createList('agent', 3, 'withConsulLink', 'withVaultLink');
}
+function rolesTestCluster(server) {
+ faker.seed(1);
+
+ server.createList('agent', 3, 'withConsulLink', 'withVaultLink');
+ server.createList('node-pool', 2);
+ server.createList('node', 5);
+ server.createList('job', 5);
+
+ // createTokens(server);
+
+ // Create policies
+ const clientReaderPolicy = server.create('policy', {
+ id: 'client-reader',
+ name: 'client-reader',
+ description: "Can read nodes and that's about it",
+ rulesJSON: {
+ Node: {
+ Policy: 'read',
+ },
+ },
+ });
+
+ const clientWriterPolicy = server.create('policy', {
+ id: 'client-writer',
+ name: 'client-writer',
+ description: 'Can write to nodes',
+ rulesJSON: {
+ Node: {
+ Policy: 'write',
+ },
+ },
+ });
+
+ const clientDenierPolicy = server.create('policy', {
+ id: 'client-denier',
+ name: 'client-denier',
+ description: "Can't do anything with Clients",
+ rulesJSON: {
+ Node: {
+ Policy: 'deny',
+ },
+ },
+ });
+
+ const jobDenierPolicy = server.create('policy', {
+ id: 'job-denier',
+ name: 'job-denier',
+ description: "Can't do anything with Jobs",
+ rulesJSON: {
+ namespace: {
+ '*': {
+ policy: 'deny',
+ },
+ },
+ },
+ });
+
+ const operatorPolicy = server.create('policy', {
+ id: 'operator',
+ name: 'operator',
+ description: 'Can operate',
+ rulesJSON: {
+ operator: {
+ policy: 'write',
+ },
+ },
+ });
+
+ const jobReaderPolicy = server.create('policy', {
+ id: 'job-reader',
+ name: 'job-reader',
+ description: 'Can learn about jobs',
+ rulesJSON: {
+ namespace: {
+ '*': {
+ policy: 'read',
+ },
+ },
+ },
+ });
+
+ const highLevelJobPolicy = server.create('policy', {
+ id: 'job-writer',
+ name: 'job-writer',
+ description: 'Can do lots with jobs',
+ rulesJSON: {
+ Namespaces: [
+ {
+ Name: '*',
+ Policy: '',
+ Capabilities: ['submit-job'],
+ Variables: null,
+ },
+ ],
+ },
+ });
+
+ // Create roles
+ const editorRole = server.create('role', {
+ id: 'editor',
+ name: 'editor',
+ description: 'Can edit things',
+ policyIds: [clientWriterPolicy.id],
+ });
+
+ const highLevelRole = server.create('role', {
+ id: 'high-level',
+ name: 'high-level',
+ description: 'Can do lots of things',
+ policyIds: [highLevelJobPolicy.id],
+ });
+
+ const readerRole = server.create('role', {
+ id: 'reader',
+ name: 'reader',
+ description: 'Can read things',
+ policyIds: [clientReaderPolicy.id, jobReaderPolicy.id],
+ });
+
+ const denierRole = server.create('role', {
+ id: 'denier',
+ name: 'denier',
+ description: "Can't do anything",
+ policyIds: [clientDenierPolicy.id, jobDenierPolicy.id],
+ });
+
+ // Create tokens
+
+ let managementToken = server.create('token', {
+ type: 'management',
+ name: 'Management Token',
+ });
+
+ let clientReaderToken = server.create('token', {
+ type: 'client',
+ name: "N. O'DeReader",
+ policyIds: [clientReaderPolicy.id],
+ });
+
+ let clientWriterToken = server.create('token', {
+ type: 'client',
+ name: "N. O'DeWriter",
+ policyIds: [clientWriterPolicy.id],
+ });
+
+ let dualPolicyToken = server.create('token', {
+ type: 'client',
+ name: 'Multi-policy Token',
+ policyIds: [clientReaderPolicy.id, clientWriterPolicy.id],
+ });
+
+ let highLevelViaPolicyToken = server.create('token', {
+ type: 'client',
+ name: 'High Level Policy Token',
+ policyIds: [highLevelJobPolicy.id],
+ });
+
+ let highLevelViaRoleToken = server.create('token', {
+ type: 'client',
+ name: 'High Level Role Token',
+ roleIds: [highLevelRole.id],
+ });
+
+ let policyAndRoleToken = server.create('token', {
+ type: 'client',
+ name: 'Policy And Role Token',
+ policyIds: [operatorPolicy.id],
+ roleIds: [readerRole.id],
+ });
+
+ let multiRoleToken = server.create('token', {
+ type: 'client',
+ name: 'Multi Role Token',
+ roleIds: [editorRole.id, highLevelRole.id],
+ });
+
+ let multiRoleAndPolicyToken = server.create('token', {
+ type: 'client',
+ name: 'Multi Role And Policy Token',
+ roleIds: [editorRole.id, highLevelRole.id],
+ policyIds: [clientWriterPolicy.id], // also included within editorRole, so redundant here.
+ });
+
+ let noClientsViaPolicyToken = server.create('token', {
+ type: 'client',
+ name: 'Clientless Policy Token',
+ policyIds: [clientDenierPolicy.id],
+ });
+
+ let noClientsViaRoleToken = server.create('token', {
+ type: 'client',
+ name: 'Clientless Role Token',
+ roleIds: [denierRole.id],
+ });
+
+ // malleable test token
+ server.create('token', {
+ name: 'Clay-Token',
+ id: 'cl4y-t0k3n',
+ type: 'client',
+ policyIds: [clientReaderPolicy.id, operatorPolicy.id],
+ roleIds: [editorRole.id],
+ expirationTime: new Date(new Date().getTime() + 60 * 60 * 1000),
+ });
+
+ logTokens(server);
+
+ server.create('auth-method', { name: 'vault' });
+
+ server.createList('agent', 3, 'withConsulLink', 'withVaultLink');
+}
+
function servicesTestCluster(server) {
faker.seed(1);
server.create('feature', { name: 'Dynamic Application Sizing' });
diff --git a/ui/mirage/serializers/role.js b/ui/mirage/serializers/role.js
new file mode 100644
index 000000000..eb906c034
--- /dev/null
+++ b/ui/mirage/serializers/role.js
@@ -0,0 +1,29 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+// @ts-check
+import ApplicationSerializer from './application';
+
+export default ApplicationSerializer.extend({
+ serializeIds: 'always',
+
+ serialize() {
+ var json = ApplicationSerializer.prototype.serialize.apply(this, arguments);
+ if (json instanceof Array) {
+ json.forEach(serializeRole);
+ } else {
+ serializeRole(json);
+ }
+ return json;
+ },
+});
+
+function serializeRole(role) {
+ role.Policies = (role.Policies || []).map((policy) => {
+ return { ID: policy, Name: policy };
+ });
+ delete role.PolicyIDs;
+ return role;
+}
diff --git a/ui/mirage/serializers/token.js b/ui/mirage/serializers/token.js
index b1b597528..0dff1c96c 100644
--- a/ui/mirage/serializers/token.js
+++ b/ui/mirage/serializers/token.js
@@ -12,6 +12,29 @@ export default ApplicationSerializer.extend({
if (relationship === 'policies') {
return 'Policies';
}
- return ApplicationSerializer.prototype.keyForRelationshipIds.apply(this, arguments);
+ if (relationship === 'roles') {
+ return 'Roles';
+ }
+ return ApplicationSerializer.prototype.keyForRelationshipIds.apply(
+ this,
+ arguments
+ );
+ },
+
+ serialize() {
+ var json = ApplicationSerializer.prototype.serialize.apply(this, arguments);
+ if (json instanceof Array) {
+ json.forEach(serializeToken);
+ } else {
+ serializeToken(json);
+ }
+ return json;
},
});
+
+function serializeToken(token) {
+ token.Roles = (token.Roles || []).map((role) => {
+ return { ID: role, Name: role };
+ });
+ return token;
+}
diff --git a/ui/package.json b/ui/package.json
index 9072a8bea..247a225b0 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -87,7 +87,7 @@
"ember-cli-sri": "^2.1.1",
"ember-cli-string-helpers": "^6.1.0",
"ember-cli-terser": "^4.0.2",
- "ember-click-outside": "^3.0.0",
+ "ember-click-outside": "^5.0.0",
"ember-composable-helpers": "^5.0.0",
"ember-concurrency": "^2.2.1",
"ember-copy": "^2.0.1",
@@ -178,7 +178,7 @@
},
"dependencies": {
"@babel/helper-string-parser": "^7.19.4",
- "@hashicorp/design-system-components": "^2.6.0",
+ "@hashicorp/design-system-components": "^2.12.0",
"@hashicorp/ember-flight-icons": "^3.0.4",
"@percy/cli": "^1.6.1",
"@percy/ember": "^3.0.0",
diff --git a/ui/tests/acceptance/access-control-test.js b/ui/tests/acceptance/access-control-test.js
new file mode 100644
index 000000000..82e00eb5f
--- /dev/null
+++ b/ui/tests/acceptance/access-control-test.js
@@ -0,0 +1,150 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { module, test } from 'qunit';
+import { currentURL, triggerKeyEvent } from '@ember/test-helpers';
+import { setupApplicationTest } from 'ember-qunit';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import AccessControl from 'nomad-ui/tests/pages/access-control';
+import Tokens from 'nomad-ui/tests/pages/settings/tokens';
+import { allScenarios } from '../../mirage/scenarios/default';
+import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
+
+// Several related tests within Access Control are contained in the Tokens, Roles,
+// and Policies acceptance tests.
+
+module('Acceptance | access control', function (hooks) {
+ setupApplicationTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(function () {
+ window.localStorage.clear();
+ window.sessionStorage.clear();
+ // server.create('token');
+ allScenarios.rolesTestCluster(server);
+ });
+
+ test('Access Control is only accessible by a management user', async function (assert) {
+ assert.expect(7);
+ await AccessControl.visit();
+
+ assert.equal(
+ currentURL(),
+ '/jobs',
+ 'redirected to the jobs page if a non-management token on /access-control'
+ );
+
+ await AccessControl.visitTokens();
+ assert.equal(
+ currentURL(),
+ '/jobs',
+ 'redirected to the jobs page if a non-management token on /tokens'
+ );
+
+ assert.dom('[data-test-gutter-link="access-control"]').doesNotExist();
+
+ await Tokens.visit();
+ const managementToken = server.db.tokens.findBy(
+ (t) => t.type === 'management'
+ );
+ const { secretId } = managementToken;
+ await Tokens.secret(secretId).submit();
+
+ assert.dom('[data-test-gutter-link="access-control"]').exists();
+
+ await AccessControl.visit();
+ assert.equal(
+ currentURL(),
+ '/access-control',
+ 'management token can access /access-control'
+ );
+
+ await a11yAudit(assert);
+
+ await AccessControl.visitTokens();
+ assert.equal(
+ currentURL(),
+ '/access-control/tokens',
+ 'management token can access /access-control/tokens'
+ );
+ });
+
+ test('Access control index content', async function (assert) {
+ await Tokens.visit();
+ const managementToken = server.db.tokens.findBy(
+ (t) => t.type === 'management'
+ );
+ const { secretId } = managementToken;
+ await Tokens.secret(secretId).submit();
+
+ await AccessControl.visit();
+ assert.dom('[data-test-tokens-card]').exists();
+ assert.dom('[data-test-roles-card]').exists();
+ assert.dom('[data-test-policies-card]').exists();
+
+ const numberOfTokens = server.db.tokens.length;
+ const numberOfRoles = server.db.roles.length;
+ const numberOfPolicies = server.db.policies.length;
+
+ assert
+ .dom('[data-test-tokens-card] a')
+ .includesText(`${numberOfTokens} Tokens`);
+ assert
+ .dom('[data-test-roles-card] a')
+ .includesText(`${numberOfRoles} Roles`);
+ assert
+ .dom('[data-test-policies-card] a')
+ .includesText(`${numberOfPolicies} Policies`);
+ });
+
+ test('Access control subnav', async function (assert) {
+ await Tokens.visit();
+ const managementToken = server.db.tokens.findBy(
+ (t) => t.type === 'management'
+ );
+ const { secretId } = managementToken;
+ await Tokens.secret(secretId).submit();
+
+ await AccessControl.visit();
+
+ assert.equal(currentURL(), '/access-control');
+
+ await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', {
+ shiftKey: true,
+ });
+ assert.equal(
+ currentURL(),
+ `/access-control/tokens`,
+ 'Shift+ArrowRight takes you to the next tab (Tokens)'
+ );
+
+ await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', {
+ shiftKey: true,
+ });
+ assert.equal(
+ currentURL(),
+ `/access-control/roles`,
+ 'Shift+ArrowRight takes you to the next tab (Roles)'
+ );
+
+ await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', {
+ shiftKey: true,
+ });
+ assert.equal(
+ currentURL(),
+ `/access-control/policies`,
+ 'Shift+ArrowRight takes you to the next tab (Policies)'
+ );
+
+ await triggerKeyEvent('.page-layout', 'keydown', 'ArrowRight', {
+ shiftKey: true,
+ });
+ assert.equal(
+ currentURL(),
+ `/access-control`,
+ 'Shift+ArrowLeft takes you back to the Access Control index page'
+ );
+ });
+});
diff --git a/ui/tests/acceptance/global-header-test.js b/ui/tests/acceptance/global-header-test.js
index b34a0927c..e38c9bdfa 100644
--- a/ui/tests/acceptance/global-header-test.js
+++ b/ui/tests/acceptance/global-header-test.js
@@ -74,7 +74,7 @@ module('Acceptance | global header', function (hooks) {
assert.false(Layout.navbar.end.signInLink.isVisible);
await Layout.navbar.end.profileDropdown.open();
- await click('.dropdown-options .ember-power-select-option:nth-child(1)');
+ await click('[data-test-profile-dropdown-profile-link]');
assert.equal(
currentURL(),
'/settings/tokens',
@@ -82,7 +82,7 @@ module('Acceptance | global header', function (hooks) {
);
await Layout.navbar.end.profileDropdown.open();
- await click('.dropdown-options .ember-power-select-option:nth-child(2)');
+ await click('[data-test-profile-dropdown-sign-out-link]');
assert.equal(window.localStorage.nomadTokenSecret, null, 'Token is wiped');
assert.equal(currentURL(), '/jobs', 'After signout, back on the jobs page');
});
diff --git a/ui/tests/acceptance/policies-test.js b/ui/tests/acceptance/policies-test.js
index 73222741c..bc1d839a3 100644
--- a/ui/tests/acceptance/policies-test.js
+++ b/ui/tests/acceptance/policies-test.js
@@ -19,9 +19,9 @@ module('Acceptance | policies', function (hooks) {
assert.expect(4);
allScenarios.policiesTestCluster(server);
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
- await visit('/policies');
- assert.dom('[data-test-gutter-link="policies"]').exists();
- assert.equal(currentURL(), '/policies');
+ await visit('/access-control/policies');
+ assert.dom('[data-test-gutter-link="access-control"]').exists();
+ assert.equal(currentURL(), '/access-control/policies');
assert
.dom('[data-test-policy-row]')
.exists({ count: server.db.policies.length });
@@ -34,9 +34,9 @@ module('Acceptance | policies', function (hooks) {
test('Prevents policies access if you lack a management token', async function (assert) {
allScenarios.policiesTestCluster(server);
window.localStorage.nomadTokenSecret = server.db.tokens[1].secretId;
- await visit('/policies');
+ await visit('/access-control/policies');
assert.equal(currentURL(), '/jobs');
- assert.dom('[data-test-gutter-link="policies"]').doesNotExist();
+ assert.dom('[data-test-gutter-link="access-control"]').doesNotExist();
// Reset Token
window.localStorage.nomadTokenSecret = null;
});
@@ -44,51 +44,80 @@ module('Acceptance | policies', function (hooks) {
test('Modifying an existing policy', async function (assert) {
allScenarios.policiesTestCluster(server);
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
- await visit('/policies');
- await click('[data-test-policy-row]:first-child');
- assert.equal(currentURL(), `/policies/${server.db.policies[0].name}`);
+ await visit('/access-control/policies');
+ await click('[data-test-policy-row]:first-child a');
+ // Table sorts by name by default
+ let firstPolicy = server.db.policies.sort((a, b) => {
+ return a.name.localeCompare(b.name);
+ })[0];
+ assert.equal(currentURL(), `/access-control/policies/${firstPolicy.name}`);
assert.dom('[data-test-policy-editor]').exists();
- assert.dom('[data-test-title]').includesText(server.db.policies[0].name);
- await click('button[type="submit"]');
+ assert.dom('[data-test-title]').includesText(firstPolicy.name);
+ await click('button[data-test-save-policy]');
assert.dom('.flash-message.alert-success').exists();
assert.equal(
currentURL(),
- `/policies/${server.db.policies[0].name}`,
+ `/access-control/policies/${firstPolicy.name}`,
'remain on page after save'
);
// Reset Token
window.localStorage.nomadTokenSecret = null;
});
+ test('Creating a test token', async function (assert) {
+ allScenarios.policiesTestCluster(server);
+ window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
+ await visit('/access-control/policies');
+ await click('[data-test-policy-name="Variable-Maker"]');
+ assert.equal(currentURL(), '/access-control/policies/Variable-Maker');
+ await click('[data-test-create-test-token]');
+ assert.dom('.flash-message.alert-success').exists();
+ assert
+ .dom('[data-test-token-name="Example Token for Variable-Maker"]')
+ .exists('Test token is created and visible');
+ const newTokenRow = [
+ ...findAll('[data-test-token-name="Example Token for Variable-Maker"]'),
+ ][0].parentElement;
+ const newTokenDeleteButton = newTokenRow.querySelector(
+ '[data-test-delete-token-button]'
+ );
+ await click(newTokenDeleteButton);
+ assert
+ .dom('[data-test-token-name="Example Token for Variable-Maker"]')
+ .doesNotExist('Token is deleted');
+ // Reset Token
+ window.localStorage.nomadTokenSecret = null;
+ });
+
test('Creating a new policy', async function (assert) {
assert.expect(7);
allScenarios.policiesTestCluster(server);
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
- await visit('/policies');
+ await visit('/access-control/policies');
await click('[data-test-create-policy]');
- assert.equal(currentURL(), '/policies/new');
+ assert.equal(currentURL(), '/access-control/policies/new');
await typeIn('[data-test-policy-name-input]', 'My Fun Policy');
- await click('button[type="submit"]');
+ await click('button[data-test-save-policy]');
assert
.dom('.flash-message.alert-critical')
.exists('Doesnt let you save a bad name');
- assert.equal(currentURL(), '/policies/new');
+ assert.equal(currentURL(), '/access-control/policies/new');
document.querySelector('[data-test-policy-name-input]').value = ''; // clear
await typeIn('[data-test-policy-name-input]', 'My-Fun-Policy');
- await click('button[type="submit"]');
+ await click('button[data-test-save-policy]');
assert.dom('.flash-message.alert-success').exists();
assert.equal(
currentURL(),
- '/policies/My-Fun-Policy',
+ '/access-control/policies/My-Fun-Policy',
'redirected to the now-created policy'
);
- await visit('/policies');
+ await visit('/access-control/policies');
const newPolicy = [...findAll('[data-test-policy-name]')].filter((a) =>
a.textContent.includes('My-Fun-Policy')
)[0];
assert.ok(newPolicy, 'Policy is in the list');
await click(newPolicy);
- assert.equal(currentURL(), '/policies/My-Fun-Policy');
+ assert.equal(currentURL(), '/access-control/policies/My-Fun-Policy');
await percySnapshot(assert);
// Reset Token
window.localStorage.nomadTokenSecret = null;
@@ -97,20 +126,44 @@ module('Acceptance | policies', function (hooks) {
test('Deleting a policy', async function (assert) {
allScenarios.policiesTestCluster(server);
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
- await visit('/policies');
- const firstPolicyName = server.db.policies[0].name;
- const firstPolicyRow = [...findAll('[data-test-policy-name]')].filter(
+ await visit('/access-control/policies');
+ let firstPolicy = server.db.policies.sort((a, b) => {
+ return a.name.localeCompare(b.name);
+ })[0];
+
+ const firstPolicyName = firstPolicy.name;
+ const firstPolicyLink = [...findAll('[data-test-policy-name]')].filter(
(row) => row.textContent.includes(firstPolicyName)
)[0];
- await click(firstPolicyRow);
- assert.equal(currentURL(), `/policies/${firstPolicyName}`);
- await click('[data-test-delete-button] button');
- assert.dom('[data-test-confirm-button]').exists();
- await click('[data-test-confirm-button]');
+ await click(firstPolicyLink);
+ assert.equal(currentURL(), `/access-control/policies/${firstPolicyName}`);
+ await click('[data-test-delete-policy]');
assert.dom('.flash-message.alert-success').exists();
- assert.equal(currentURL(), '/policies');
+ assert.equal(currentURL(), '/access-control/policies');
assert.dom(`[data-test-policy-name="${firstPolicyName}"]`).doesNotExist();
// Reset Token
window.localStorage.nomadTokenSecret = null;
});
+
+ test('Policies Index', async function (assert) {
+ allScenarios.policiesTestCluster(server);
+ window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
+ await visit('/access-control/policies');
+ // Table contains every policy in db
+ assert
+ .dom('[data-test-policy-row]')
+ .exists({ count: server.db.policies.length });
+
+ assert.dom('[data-test-empty-policies-list-headline]').doesNotExist();
+
+ // Deleting all policies results in a message
+ const policyRows = findAll('[data-test-policy-row]');
+ for (const row of policyRows) {
+ const deleteButton = row.querySelector('[data-test-delete-policy]');
+ await click(deleteButton);
+ }
+ assert.dom('[data-test-empty-policies-list-headline]').exists();
+ // Reset Token
+ window.localStorage.nomadTokenSecret = null;
+ });
});
diff --git a/ui/tests/acceptance/regions-test.js b/ui/tests/acceptance/regions-test.js
index 39dd64f1e..d565ec667 100644
--- a/ui/tests/acceptance/regions-test.js
+++ b/ui/tests/acceptance/regions-test.js
@@ -194,29 +194,34 @@ module('Acceptance | regions (many)', function (hooks) {
await JobsList.jobs.objectAt(0).clickRow();
await Layout.gutter.visitClients();
await Layout.gutter.visitServers();
- const [
- ,
- ,
- ,
- // License request
- // Token/policies request
- // Search feature detection
- regionsRequest,
- defaultRegionRequest,
- ...appRequests
- ] = server.pretender.handledRequests;
+
+ const regionsRequest = server.pretender.handledRequests.find((req) =>
+ req.responseURL.includes('/v1/regions')
+ );
+ const licenseRequest = server.pretender.handledRequests.find((req) =>
+ req.responseURL.includes('/v1/operator/license')
+ );
+ const appRequests = server.pretender.handledRequests.filter(
+ (req) =>
+ !req.responseURL.includes('/v1/regions') &&
+ !req.responseURL.includes('/v1/operator/license')
+ );
assert.notOk(
regionsRequest.url.includes('region='),
'The regions request is made without a region qp'
);
assert.notOk(
- defaultRegionRequest.url.includes('region='),
+ licenseRequest.url.includes('region='),
'The default region request is made without a region qp'
);
appRequests.forEach((req) => {
- if (req.url === '/v1/agent/self') {
+ if (
+ req.url === '/v1/agent/self' ||
+ req.url === '/v1/acl/token/self' ||
+ req.url === '/v1/agent/members'
+ ) {
assert.notOk(req.url.includes('region='), `(no region) ${req.url}`);
} else {
assert.ok(req.url.includes(`region=${region}`), req.url);
diff --git a/ui/tests/acceptance/roles-test.js b/ui/tests/acceptance/roles-test.js
new file mode 100644
index 000000000..5a62990a9
--- /dev/null
+++ b/ui/tests/acceptance/roles-test.js
@@ -0,0 +1,295 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { module, test } from 'qunit';
+import { findAll, fillIn, find, click, currentURL } from '@ember/test-helpers';
+import { setupApplicationTest } from 'ember-qunit';
+import { setupMirage } from 'ember-cli-mirage/test-support';
+import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
+import { allScenarios } from '../../mirage/scenarios/default';
+import Tokens from 'nomad-ui/tests/pages/settings/tokens';
+import AccessControl from 'nomad-ui/tests/pages/access-control';
+import percySnapshot from '@percy/ember';
+
+module('Acceptance | roles', function (hooks) {
+ setupApplicationTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(async function () {
+ window.localStorage.clear();
+ window.sessionStorage.clear();
+ allScenarios.rolesTestCluster(server);
+ await Tokens.visit();
+ const managementToken = server.db.tokens.findBy(
+ (t) => t.type === 'management'
+ );
+ const { secretId } = managementToken;
+ await Tokens.secret(secretId).submit();
+ await AccessControl.visitRoles();
+ });
+
+ hooks.afterEach(async function () {
+ await Tokens.visit();
+ await Tokens.clear();
+ });
+
+ test('Roles index, general', async function (assert) {
+ assert.expect(3);
+ await a11yAudit(assert);
+
+ assert.equal(currentURL(), '/access-control/roles');
+
+ assert
+ .dom('[data-test-role-row]')
+ .exists({ count: server.db.roles.length });
+
+ await percySnapshot(assert);
+ });
+
+ test('Roles index: deletion', async function (assert) {
+ // Delete every role
+ assert
+ .dom('[data-test-empty-role-list-headline]')
+ .doesNotExist('no empty state');
+ const roleRows = findAll('[data-test-role-row]');
+ for (const row of roleRows) {
+ const deleteButton = row.querySelector('[data-test-delete-role]');
+ await click(deleteButton);
+ }
+ // there should be as many success messages as there were roles
+ assert
+ .dom('.flash-message.alert-success')
+ .exists({ count: roleRows.length });
+
+ assert.dom('[data-test-empty-role-list-headline]').exists('empty state');
+ });
+
+ test('Roles have policies lists', async function (assert) {
+ const role = server.db.roles.findBy((r) => r.name === 'reader');
+ const roleRow = find(`[data-test-role-row="${role.name}"]`);
+ const rolePoliciesCell = roleRow.querySelector('[data-test-role-policies]');
+ const policiesCellTags = rolePoliciesCell
+ .querySelector('.tag-group')
+ .querySelectorAll('span');
+ assert.equal(policiesCellTags.length, 2);
+ assert.equal(policiesCellTags[0].textContent.trim(), 'client-reader');
+ assert.equal(policiesCellTags[1].textContent.trim(), 'job-reader');
+
+ await click(policiesCellTags[0].querySelector('a'));
+ assert.equal(currentURL(), '/access-control/policies/client-reader');
+ assert.dom('[data-test-title]').containsText('client-reader');
+ });
+
+ test('Edit Role: Name and Description', async function (assert) {
+ assert.expect(8);
+ const role = server.db.roles.findBy((r) => r.name === 'reader');
+ await click('[data-test-role-name="reader"] a');
+ assert.equal(currentURL(), `/access-control/roles/${role.id}`);
+
+ assert.dom('[data-test-role-name-input]').hasValue(role.name);
+ assert.dom('[data-test-role-description-input]').hasValue(role.description);
+ assert.dom('[data-test-role-policies]').exists();
+
+ // Modify the name and description
+ await fillIn('[data-test-role-name-input]', 'reader-edited');
+ await fillIn('[data-test-role-description-input]', 'edited description');
+ await click('button[data-test-save-role]');
+ assert.dom('.flash-message.alert-success').exists();
+ assert.equal(
+ currentURL(),
+ `/access-control/roles/${role.name}`,
+ 'remain on page after save'
+ );
+ await percySnapshot(assert);
+
+ // Go back to the roles index
+ await AccessControl.visitRoles();
+ let readerRoleRow = find('[data-test-role-row="reader-edited"]');
+ assert.dom(readerRoleRow).exists();
+ assert.equal(
+ readerRoleRow
+ .querySelector('[data-test-role-description]')
+ .textContent.trim(),
+ 'edited description'
+ );
+ });
+
+ test('Edit Role: Policies', async function (assert) {
+ const role = server.db.roles.findBy((r) => r.name === 'reader');
+ await click('[data-test-role-name="reader"] a');
+ assert.equal(currentURL(), `/access-control/roles/${role.id}`);
+
+ // Policies table is sortable
+
+ const nameCells = findAll('[data-test-policy-name]');
+ const nameCellText = nameCells.map((cell) => cell.textContent.trim());
+ const sortedNameCellText = nameCellText.slice().sort();
+ assert.deepEqual(
+ nameCellText,
+ sortedNameCellText,
+ 'Policy names are sorted alphabetically'
+ );
+
+ // Click on the second thead tr th to reverse
+ assert
+ .dom('table[data-test-role-policies] thead tr th:nth-child(2)')
+ .hasAttribute('aria-sort', 'ascending');
+ // await click('table[data-test-role-policies] thead tr th:nth-child(2)');
+ // above didnt work, another way?
+ await click('[data-test-role-policies] thead tr th:nth-child(2) button');
+ assert
+ .dom('table[data-test-role-policies] thead tr th:nth-child(2)')
+ .hasAttribute('aria-sort', 'descending');
+
+ const reversedNameCells = findAll('[data-test-policy-name]');
+ const reversedNameCellText = reversedNameCells.map((cell) =>
+ cell.textContent.trim()
+ );
+ const reversedSortedNameCellText = nameCellText.slice().sort().reverse();
+
+ assert.deepEqual(
+ reversedNameCellText,
+ reversedSortedNameCellText,
+ 'Names are reversed alphabetically after click'
+ );
+
+ // Make sure the correct policies are checked
+ const rolePolicies = role.policyIds;
+ // All possible policies are shown
+ const allPolicies = server.db.policies;
+ assert.equal(
+ findAll('[data-test-role-policies] tbody tr').length,
+ allPolicies.length,
+ 'all policies are shown'
+ );
+
+ const checkedPolicyRows = findAll(
+ '[data-test-role-policies] tbody tr input:checked'
+ );
+
+ assert.equal(
+ checkedPolicyRows.length,
+ rolePolicies.length,
+ 'correct number of policies are checked'
+ );
+
+ const checkedPolicyNames = checkedPolicyRows.map((row) =>
+ row
+ .closest('tr')
+ .querySelector('[data-test-policy-name]')
+ .textContent.trim()
+ );
+
+ assert.deepEqual(
+ checkedPolicyNames.sort(),
+ rolePolicies.sort(),
+ 'All policies belonging to this role are checked'
+ );
+
+ // Try de-selecting all policies and saving
+ checkedPolicyRows.forEach((row) => row.click());
+ await click('button[data-test-save-role]');
+ assert
+ .dom('.flash-message.alert-critical')
+ .exists('Doesnt let you save with no policies selected');
+
+ // Check all policies
+ findAll('[data-test-role-policies] tbody tr input').forEach((row) =>
+ row.click()
+ );
+ await click('button[data-test-save-role]');
+ assert.dom('.flash-message.alert-success').exists();
+
+ await AccessControl.visitRoles();
+ const readerRoleRow = find('[data-test-role-row="reader"]');
+ const readerRolePolicies = readerRoleRow
+ .querySelector('[data-test-role-policies]')
+ .querySelectorAll('span');
+ assert.equal(
+ readerRolePolicies.length,
+ allPolicies.length,
+ 'all policies are attached to the role at index level'
+ );
+ });
+
+ test('Edit Role: Tokens', async function (assert) {
+ assert.expect(10);
+ const role = server.db.roles.findBy((r) => r.name === 'reader');
+
+ await click('[data-test-role-name="reader"] a');
+ assert.equal(currentURL(), `/access-control/roles/${role.id}`);
+ assert.dom('table.tokens').exists();
+
+ // "Reader" role has a single token with it applied by default
+ assert.dom('[data-test-role-token-row]').exists({ count: 1 });
+
+ // Delete it; should get a nice No Tokens message
+ await click('[data-test-delete-token-button]');
+ assert.dom('.flash-message.alert-success').exists();
+ assert.dom('[data-test-role-token-row]').doesNotExist();
+ assert.dom('[data-test-empty-role-list-headline]').exists();
+ // Create two test tokens
+ await click('[data-test-create-test-token]');
+ assert.dom('[data-test-empty-role-list-headline]').doesNotExist();
+ await click('[data-test-create-test-token]');
+ assert
+ .dom('[data-test-role-token-row]')
+ .exists({ count: 2 }, 'Test tokens are included on the page');
+ assert
+ .dom('[data-test-role-token-row]:last-child [data-test-token-name]')
+ .hasText(`Example Token for ${role.name}`);
+
+ await percySnapshot(assert);
+
+ await AccessControl.visitTokens();
+ assert
+ .dom('[data-test-token-name="Example Token for reader"]')
+ .exists(
+ { count: 2 },
+ 'The two newly-created tokens are listed on the tokens index page'
+ );
+ });
+ test('Edit Role: Deletion', async function (assert) {
+ const role = server.db.roles.findBy((r) => r.name === 'reader');
+ await click('[data-test-role-name="reader"] a');
+ assert.equal(currentURL(), `/access-control/roles/${role.id}`);
+ await click('[data-test-delete-role]');
+ assert.dom('.flash-message.alert-success').exists();
+ assert.equal(currentURL(), '/access-control/roles');
+ assert.dom('[data-test-role-row="reader"]').doesNotExist();
+ });
+ test('New Role', async function (assert) {
+ await click('[data-test-create-role]');
+ assert.equal(currentURL(), '/access-control/roles/new');
+ await fillIn('[data-test-role-name-input]', 'test-role');
+ await click('button[data-test-save-role]');
+ assert
+ .dom('.flash-message.alert-critical')
+ .exists('Cannnot save with no policies selected');
+
+ // Select a policy
+ await click('[data-test-role-policies] tbody tr input');
+ await click('button[data-test-save-role]');
+ assert.dom('.flash-message.alert-success').exists();
+ assert.equal(currentURL(), '/access-control/roles/1'); // default id created via mirage
+ await AccessControl.visitRoles();
+ assert.dom('[data-test-role-row="test-role"]').exists();
+
+ // Now, try deleting all policies then doing this again. There'll be a warning on the roles/new page.
+ await AccessControl.visitPolicies();
+ const policyRows = findAll('[data-test-policy-row]');
+ for (const row of policyRows) {
+ const deleteButton = row.querySelector('[data-test-delete-policy]');
+ await click(deleteButton);
+ }
+ assert.dom('[data-test-empty-policies-list-headline]').exists();
+ await AccessControl.visitRoles();
+ await click('[data-test-create-role]');
+ assert.dom('.empty-message').exists();
+ assert
+ .dom('.empty-message-body')
+ .containsText('At least one Policy is required to create a Role');
+ });
+});
diff --git a/ui/tests/acceptance/token-test.js b/ui/tests/acceptance/token-test.js
index e6ce82838..83832a169 100644
--- a/ui/tests/acceptance/token-test.js
+++ b/ui/tests/acceptance/token-test.js
@@ -4,7 +4,14 @@
*/
/* eslint-disable qunit/require-expect */
-import { currentURL, find, findAll, visit, click } from '@ember/test-helpers';
+import {
+ currentURL,
+ find,
+ findAll,
+ visit,
+ click,
+ fillIn,
+} from '@ember/test-helpers';
import { module, skip, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
@@ -14,6 +21,7 @@ import Jobs from 'nomad-ui/tests/pages/jobs/list';
import JobDetail from 'nomad-ui/tests/pages/jobs/detail';
import ClientDetail from 'nomad-ui/tests/pages/clients/detail';
import Layout from 'nomad-ui/tests/pages/layout';
+import AccessControl from 'nomad-ui/tests/pages/access-control';
import percySnapshot from '@percy/ember';
import faker from 'nomad-ui/mirage/faker';
import moment from 'moment';
@@ -28,6 +36,7 @@ let job;
let node;
let managementToken;
let clientToken;
+
module('Acceptance | tokens', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
@@ -61,7 +70,7 @@ module('Acceptance | tokens', function (hooks) {
null,
'No token secret set'
);
- assert.ok(document.title.includes('Authorization'));
+ assert.ok(document.title.includes('Sign In'));
await Tokens.secret(secretId).submit();
assert.equal(
@@ -567,7 +576,6 @@ module('Acceptance | tokens', function (hooks) {
assert.dom('.dropdown-options').exists('Dropdown options are shown');
await selectChoose('[data-test-select-jwt]', 'JWT-Regional');
- console.log(currentURL());
assert.equal(
currentURL(),
'/settings/tokens?jwtAuthMethod=JWT-Regional',
@@ -586,21 +594,24 @@ module('Acceptance | tokens', function (hooks) {
);
});
- test('Tokens are shown on the policies index page', async function (assert) {
+ test('Tokens are shown on the Access Control Policies index page', async function (assert) {
allScenarios.policiesTestCluster(server);
+ let firstPolicy = server.db.policies.sort((a, b) => {
+ return a.name.localeCompare(b.name);
+ })[0];
// Create an expired token
server.create('token', {
name: 'Expired Token',
id: 'just-expired',
- policyIds: [server.db.policies[0].name],
+ policyIds: [firstPolicy.name],
expirationTime: new Date(new Date().getTime() - 10 * 60 * 1000), // 10 minutes ago
});
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
- await visit('/policies');
- assert.dom('[data-test-policy-token-count]').exists();
+ await visit('/access-control/policies');
+ assert.dom('[data-test-policy-total-tokens]').exists();
const expectedFirstPolicyTokens = server.db.tokens.filter((token) => {
- return token.policyIds.includes(server.db.policies[0].name);
+ return token.policyIds.includes(firstPolicy.name);
});
assert
.dom('[data-test-policy-total-tokens]')
@@ -611,22 +622,25 @@ module('Acceptance | tokens', function (hooks) {
test('Tokens are shown on a policy page', async function (assert) {
allScenarios.policiesTestCluster(server);
+ let firstPolicy = server.db.policies.sort((a, b) => {
+ return a.name.localeCompare(b.name);
+ })[0];
+
// Create an expired token
server.create('token', {
name: 'Expired Token',
id: 'just-expired',
- policyIds: [server.db.policies[0].name],
+ policyIds: [firstPolicy.name],
expirationTime: new Date(new Date().getTime() - 10 * 60 * 1000), // 10 minutes ago
});
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
- await visit('/policies');
-
- await click('[data-test-policy-row]:first-child');
- assert.equal(currentURL(), `/policies/${server.db.policies[0].name}`);
+ await visit('/access-control/policies');
+ await click('[data-test-policy-name]');
+ assert.equal(currentURL(), `/access-control/policies/${firstPolicy.name}`);
const expectedFirstPolicyTokens = server.db.tokens.filter((token) => {
- return token.policyIds.includes(server.db.policies[0].name);
+ return token.policyIds.includes(firstPolicy.name);
});
assert
@@ -635,14 +649,25 @@ module('Acceptance | tokens', function (hooks) {
{ count: expectedFirstPolicyTokens.length },
'Expected number of tokens are shown'
);
- assert.dom('[data-test-token-expiration-time]').hasText('10 minutes ago');
+
+ const expiredTokenRow = [...findAll('[data-test-policy-token-row]')].find(
+ (a) => a.textContent.includes('Expired Token')
+ );
+
+ assert.dom(expiredTokenRow).exists();
+ assert
+ .dom(expiredTokenRow.querySelector('[data-test-token-expiration-time]'))
+ .hasText('10 minutes ago');
window.localStorage.nomadTokenSecret = null;
});
- test('Tokens Deletion', async function (assert) {
+ test('Tokens Deletion from Policy page', async function (assert) {
allScenarios.policiesTestCluster(server);
- const testPolicy = server.db.policies[0];
+ let testPolicy = server.db.policies.sort((a, b) => {
+ return a.name.localeCompare(b.name);
+ })[0];
+
const existingTokens = server.db.tokens.filter((t) =>
t.policyIds.includes(testPolicy.name)
);
@@ -654,28 +679,22 @@ module('Acceptance | tokens', function (hooks) {
});
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
- await visit('/policies');
+ await visit('/access-control/policies');
- await click('[data-test-policy-row]:first-child');
- assert.equal(currentURL(), `/policies/${testPolicy.name}`);
+ await click('[data-test-policy-name]:first-child');
+ assert.equal(currentURL(), `/access-control/policies/${testPolicy.name}`);
assert
.dom('[data-test-policy-token-row]')
.exists(
{ count: existingTokens.length + 1 },
'Expected number of tokens are shown'
);
-
const doomedTokenRow = [...findAll('[data-test-policy-token-row]')].find(
(a) => a.textContent.includes('Doomed Token')
);
-
assert.dom(doomedTokenRow).exists();
await click(doomedTokenRow.querySelector('button'));
- assert
- .dom(doomedTokenRow.querySelector('[data-test-confirm-button]'))
- .exists();
- await click(doomedTokenRow.querySelector('[data-test-confirm-button]'));
assert.dom('.flash-message.alert-success').exists();
assert
.dom('[data-test-policy-token-row]')
@@ -687,18 +706,21 @@ module('Acceptance | tokens', function (hooks) {
window.localStorage.nomadTokenSecret = null;
});
- test('Test Token Creation', async function (assert) {
+ test('Test Token Creation from Policy Page', async function (assert) {
allScenarios.policiesTestCluster(server);
- const testPolicy = server.db.policies[0];
+ let testPolicy = server.db.policies.sort((a, b) => {
+ return a.name.localeCompare(b.name);
+ })[0];
+
const existingTokens = server.db.tokens.filter((t) =>
t.policyIds.includes(testPolicy.name)
);
window.localStorage.nomadTokenSecret = server.db.tokens[0].secretId;
- await visit('/policies');
+ await visit('/access-control/policies');
- await click('[data-test-policy-row]:first-child');
- assert.equal(currentURL(), `/policies/${testPolicy.name}`);
+ await click('[data-test-policy-name]');
+ assert.equal(currentURL(), `/access-control/policies/${testPolicy.name}`);
assert
.dom('[data-test-policy-token-row]')
@@ -730,4 +752,534 @@ module('Acceptance | tokens', function (hooks) {
requestHeaders[name.toUpperCase()]
);
}
+
+ module('Roles', function (hooks) {
+ // Set up a token with a role
+ hooks.beforeEach(function () {
+ window.localStorage.clear();
+ window.sessionStorage.clear();
+ faker.seed(1);
+ allScenarios.rolesTestCluster(server);
+ });
+
+ test('Policies are derived from role', async function (assert) {
+ assert.expect(19);
+
+ await Tokens.visit();
+
+ let token;
+
+ // User with 1 role, containing 1 policy, and no direct policies
+ token = server.db.tokens.findBy(
+ (t) => t.name === 'High Level Role Token'
+ );
+ await Tokens.secret(token.secretId).submit();
+
+ assert.dom('[data-test-token-role]').exists({ count: 1 });
+ assert.dom('[data-test-role-name]').hasText('high-level');
+ assert.dom('[data-test-role-policies] li').exists({ count: 1 });
+ assert.dom('[data-test-role-policies] li').hasText('job-writer');
+
+ assert.dom('[data-test-token-policy]').exists({ count: 1 });
+ assert.dom('[data-test-policy-name]').hasText('job-writer');
+
+ await Tokens.clear();
+
+ // User with 1 role, containing 2 policies, and a direct policy
+ token = server.db.tokens.findBy(
+ (t) => t.name === 'Policy And Role Token'
+ );
+ await Tokens.secret(token.secretId).submit();
+
+ assert.dom('[data-test-token-role]').exists({ count: 1 });
+ assert.dom('[data-test-role-name]').hasText('reader');
+ assert.dom('[data-test-role-policies] li').exists({ count: 2 });
+ let policyLinks = findAll('[data-test-role-policies] li');
+ assert.dom(policyLinks[0]).hasText('client-reader');
+ assert.dom(policyLinks[1]).hasText('job-reader');
+
+ assert.dom('[data-test-token-policy]').exists({ count: 3 });
+ let policyBlocks = findAll('[data-test-policy-name]');
+ assert.dom(policyBlocks[0]).hasText('operator');
+ assert.dom(policyBlocks[1]).hasText('client-reader');
+ assert.dom(policyBlocks[2]).hasText('job-reader');
+
+ await percySnapshot(assert);
+
+ await Tokens.clear();
+
+ // User with 2 roles, each containing 1 policy, and one of the policies is also directly on their token
+ token = server.db.tokens.findBy(
+ (t) => t.name === 'Multi Role And Policy Token'
+ );
+ await Tokens.secret(token.secretId).submit();
+
+ assert.equal(token.roleIds.length, 2);
+ assert.equal(token.policyIds.length, 1);
+
+ assert.dom('[data-test-token-role]').exists({ count: 2 });
+ assert.dom('[data-test-token-policy]').exists({ count: 2 });
+ });
+
+ test('Token priveleges are derived from role', async function (assert) {
+ // First, check that a node reader can read nodes if the policy to do so only exists at their role level
+ await visit('/clients');
+ // Expect to see some nodes
+ let nodes = server.db.nodes;
+ assert.dom('[data-test-client-node-row]').exists({ count: nodes.length });
+
+ // Head back and sign in as Clientless Role Token
+ await Tokens.visit();
+ let token = server.db.tokens.findBy(
+ (t) => t.name === 'Clientless Role Token'
+ );
+ await Tokens.secret(token.secretId).submit();
+
+ await visit('/clients');
+ // Expect no rows, and a denied message
+ assert.dom('[data-test-client-node-row]').doesNotExist();
+ assert.dom('[data-test-error]').exists();
+
+ // Pop over to the jobs page and make sure the Run button is disabled
+ await visit('/jobs');
+ assert.dom('[data-test-run-job]').hasTagName('button');
+ assert.dom('[data-test-run-job]').isDisabled();
+
+ // Sign out, and sign back in as a high-level role token
+ await Tokens.visit();
+ await Tokens.clear();
+ token = server.db.tokens.findBy(
+ (t) => t.name === 'High Level Role Token'
+ );
+ await Tokens.secret(token.secretId).submit();
+
+ await visit('/jobs');
+ // Expect the Run button/link to work now
+ assert.dom('[data-test-run-job]').hasTagName('a');
+ assert.dom('[data-test-run-job]').hasAttribute('href', '/ui/jobs/run');
+ });
+ });
+
+ module('Access Control Tokens section', function (hooks) {
+ hooks.beforeEach(async function () {
+ window.localStorage.clear();
+ window.sessionStorage.clear();
+ faker.seed(1);
+ allScenarios.rolesTestCluster(server);
+ await Tokens.visit();
+ const managementToken = server.db.tokens.findBy(
+ (t) => t.type === 'management'
+ );
+ const { secretId } = managementToken;
+ await Tokens.secret(secretId).submit();
+ await AccessControl.visitTokens();
+ });
+
+ hooks.afterEach(async function () {
+ await Tokens.visit();
+ await Tokens.clear();
+ });
+
+ test('Tokens index, general', async function (assert) {
+ assert.equal(currentURL(), '/access-control/tokens');
+ // Number of token rows equivalent to number in db
+ assert
+ .dom('[data-test-token-row]')
+ .exists({ count: server.db.tokens.length });
+
+ await percySnapshot(assert);
+ });
+
+ test('Tokens index, management token handling', async function (assert) {
+ // two management tokens, one of which is yours; yours cannot be deleted or clicked into.
+ assert.dom('[data-test-token-type="management"]').exists({ count: 2 });
+ const managementToken = server.db.tokens.findBy(
+ (t) => t.type === 'management'
+ );
+ const managementTokenRow = [...findAll('[data-test-token-row]')].find(
+ (row) => row.textContent.includes(managementToken.name)
+ );
+ const otherManagerRow = [...findAll('[data-test-token-row]')].find(
+ (row) =>
+ row.textContent.includes('management') &&
+ !row.textContent.includes(managementToken.name)
+ );
+ assert
+ .dom(managementTokenRow.querySelector('[data-test-token-name] a'))
+ .doesNotExist('Cannot click into and edit your own token');
+ assert
+ .dom(otherManagerRow.querySelector('[data-test-token-name] a'))
+ .exists('Can click into and edit another manager token');
+ assert
+ .dom(
+ managementTokenRow.querySelector('[data-test-delete-token] button')
+ )
+ .isDisabled('Cannot delete your own token');
+ assert
+ .dom(otherManagerRow.querySelector('[data-test-delete-token] button'))
+ .isNotDisabled('Can delete another manager token');
+ });
+
+ test('Tokens index, table sorting', async function (assert) {
+ const nameCells = findAll('[data-test-token-name]');
+ const nameCellText = nameCells.map((cell) => cell.textContent.trim());
+ const sortedNameCellText = nameCellText.slice().sort();
+ assert.deepEqual(
+ nameCellText,
+ sortedNameCellText,
+ 'Names are sorted alphabetically'
+ );
+
+ // Click on the first thead tr th to reverse
+ assert
+ .dom('table.acl-table thead tr th')
+ .hasAttribute('aria-sort', 'ascending');
+ await click('table.acl-table thead tr th button');
+ assert
+ .dom('table.acl-table thead tr th')
+ .hasAttribute('aria-sort', 'descending');
+
+ const reversedNameCells = findAll('[data-test-token-name]');
+ const reversedNameCellText = reversedNameCells.map((cell) =>
+ cell.textContent.trim()
+ );
+ const reversedSortedNameCellText = nameCellText.slice().sort().reverse();
+
+ assert.deepEqual(
+ reversedNameCellText,
+ reversedSortedNameCellText,
+ 'Names are reversed alphabetically'
+ );
+ });
+
+ test('Tokens index, deletion', async function (assert) {
+ const numberOfTokens = server.db.tokens.length;
+ assert
+ .dom('[data-test-token-row]')
+ .exists(
+ { count: numberOfTokens },
+ 'Number of tokens matches number in db'
+ );
+ const tokenToDelete = server.db.tokens.findBy((t) => t.type === 'client');
+ const tokenRowToDelete = [...findAll('[data-test-token-row]')].find(
+ (row) => row.textContent.includes(tokenToDelete.name)
+ );
+ await click(
+ tokenRowToDelete.querySelector('[data-test-delete-token] button')
+ );
+ assert.dom('.flash-message.alert-success').exists();
+ assert
+ .dom('[data-test-token-row]')
+ .exists(
+ { count: numberOfTokens - 1 },
+ 'Number of token rows decreased after deletion'
+ );
+
+ const nameCells = findAll('[data-test-token-name]');
+ const nameCellText = nameCells.map((cell) => cell.textContent.trim());
+ assert.notOk(
+ nameCellText.includes(tokenToDelete.name),
+ 'Deleted token name not found among name cells'
+ );
+ });
+
+ test('Tokens index, clicking into a token page', async function (assert) {
+ const tokenToClick = server.db.tokens.findBy((t) => t.type === 'client');
+ const tokenRowToClick = [...findAll('[data-test-token-row]')].find(
+ (row) => row.textContent.includes(tokenToClick.name)
+ );
+ await click(tokenRowToClick.querySelector('[data-test-token-name] a'));
+ assert.equal(currentURL(), `/access-control/tokens/${tokenToClick.id}`);
+ assert.dom('[data-test-token-name-input]').hasValue(tokenToClick.name);
+ });
+
+ test('Tokens index, roles and policies attached to a token show up as links', async function (assert) {
+ // Staying on the index page, Rows should have a Roles column with either "No Roles" or a bunch of links to roles. Ditto policies.
+ const tokenWithRolesAndPolicies = server.db.tokens.findBy(
+ (t) => t.name === 'Multi Role And Policy Token'
+ );
+ const tokenRowWithRolesAndPolicies = [
+ ...findAll('[data-test-token-row]'),
+ ].find((row) => row.textContent.includes(tokenWithRolesAndPolicies.name));
+ const rolesCell = tokenRowWithRolesAndPolicies.querySelector(
+ '[data-test-token-roles]'
+ );
+ const policiesCell = tokenRowWithRolesAndPolicies.querySelector(
+ '[data-test-token-policies]'
+ );
+ assert.dom(rolesCell).exists();
+ assert.dom(policiesCell).exists();
+
+ const rolesCellTags = rolesCell
+ .querySelector('.tag-group')
+ .querySelectorAll('span');
+ const policiesCellTags = policiesCell
+ .querySelector('.tag-group')
+ .querySelectorAll('span');
+ assert.equal(rolesCellTags.length, 2);
+ assert.equal(policiesCellTags.length, 1);
+
+ const policyLessToken = server.db.tokens.findBy(
+ (t) => t.name === 'High Level Role Token'
+ );
+ const policyLessTokenRow = [...findAll('[data-test-token-row]')].find(
+ (row) => row.textContent.includes(policyLessToken.name)
+ );
+ const rolesCell2 = policyLessTokenRow.querySelector(
+ '[data-test-token-roles]'
+ );
+ const policiesCell2 = policyLessTokenRow.querySelector(
+ '[data-test-token-policies]'
+ );
+ assert.dom(rolesCell2).exists();
+ assert.dom(policiesCell2).exists();
+
+ const rolesCellTags2 = rolesCell2
+ .querySelector('.tag-group')
+ .querySelectorAll('span');
+ const policiesCellTags2 = policiesCell2
+ .querySelector('.tag-group')
+ .querySelectorAll('span');
+ assert.equal(rolesCellTags2.length, 1);
+ assert.equal(policiesCellTags2.length, 0);
+ });
+
+ test('Token page, general', async function (assert) {
+ const token = server.db.tokens.findBy((t) => t.id === 'cl4y-t0k3n');
+ await visit(`/access-control/tokens/${token.id}`);
+ assert.dom('[data-test-token-name-input]').hasValue(token.name);
+ assert.dom('[data-test-token-accessor]').hasValue(token.accessorId);
+ assert.dom('[data-test-token-secret]').hasValue(token.secretId);
+ assert.dom('[data-test-token-type="client"]').isChecked();
+ assert.dom('[data-test-token-type="management"]').isNotChecked();
+
+ assert.dom('.expiration-time').hasText('Token expires in an hour');
+
+ assert.dom('[data-test-token-roles]').exists();
+ assert.dom('[data-test-token-policies]').exists();
+
+ // All possible policies are shown
+ const allPolicies = server.db.policies;
+ const allPolicyRows = findAll('[data-test-token-policies] tbody tr');
+ assert.equal(
+ allPolicyRows.length,
+ allPolicies.length,
+ 'All policies are shown'
+ );
+
+ // The policies/roles belonging to this token are checked
+ const tokenPolicies = token.policyIds;
+
+ const checkedPolicyRows = findAll(
+ '[data-test-token-policies] tbody tr input:checked'
+ );
+
+ assert.equal(
+ checkedPolicyRows.length,
+ tokenPolicies.length,
+ 'All policies belonging to this token are checked'
+ );
+
+ const checkedPolicyNames = checkedPolicyRows.map((row) =>
+ row
+ .closest('tr')
+ .querySelector('[data-test-policy-name]')
+ .textContent.trim()
+ );
+ assert.deepEqual(
+ checkedPolicyNames.sort(),
+ tokenPolicies.sort(),
+ 'All policies belonging to this token are checked'
+ );
+
+ const allRoles = server.db.roles;
+ const allRoleRows = findAll('[data-test-token-roles] tbody tr');
+ assert.equal(allRoleRows.length, allRoles.length, 'All roles are shown');
+
+ const tokenRoles = token.roleIds;
+
+ const checkedRoleRows = findAll(
+ '[data-test-token-roles] tbody tr input:checked'
+ );
+
+ assert.equal(
+ checkedRoleRows.length,
+ tokenRoles.length,
+ 'All roles belonging to this token are checked'
+ );
+
+ const checkedRoleNames = checkedRoleRows.map((row) =>
+ row
+ .closest('tr')
+ .querySelector('[data-test-role-name]')
+ .textContent.trim()
+ );
+
+ assert.deepEqual(
+ checkedRoleNames.sort(),
+ tokenRoles.sort(),
+ 'All roles belonging to this token are checked'
+ );
+ });
+ test('Token name can be edited', async function (assert) {
+ const token = server.db.tokens.findBy((t) => t.id === 'cl4y-t0k3n');
+ await visit(`/access-control/tokens/${token.id}`);
+ assert.dom('[data-test-token-name-input]').hasValue(token.name);
+ await fillIn('[data-test-token-name-input]', 'Mud-Token');
+ await click('[data-test-token-save]');
+ assert.dom('.flash-message.alert-success').exists();
+ await AccessControl.visitTokens();
+ assert.dom('[data-test-token-name="Mud-Token"]').exists({ count: 1 });
+ });
+
+ test('Token policies and roles can be edited', async function (assert) {
+ const token = server.db.tokens.findBy((t) => t.id === 'cl4y-t0k3n');
+ await visit(`/access-control/tokens/${token.id}`);
+
+ // The policies/roles belonging to this token are checked
+ const tokenPolicies = token.policyIds;
+
+ const checkedPolicyRows = findAll(
+ '[data-test-token-policies] tbody tr input:checked'
+ );
+
+ assert.equal(
+ checkedPolicyRows.length,
+ tokenPolicies.length,
+ 'All policies belonging to this token are checked'
+ );
+
+ const checkedPolicyNames = checkedPolicyRows.map((row) =>
+ row
+ .closest('tr')
+ .querySelector('[data-test-policy-name]')
+ .textContent.trim()
+ );
+ assert.deepEqual(
+ checkedPolicyNames.sort(),
+ tokenPolicies.sort(),
+ 'All policies belonging to this token are checked'
+ );
+
+ // Try unchecking ALL checked roles and policies and saving
+ // First, find all checked ones
+ const checkedPolicies = findAll(
+ '[data-test-token-policies] tbody tr input:checked'
+ );
+ const checkedRoles = findAll(
+ '[data-test-token-roles] tbody tr input:checked'
+ );
+ // Then uncheck them
+ checkedPolicies.forEach((policy) => {
+ policy.click();
+ });
+ checkedRoles.forEach((role) => {
+ role.click();
+ });
+ await click('[data-test-token-save]');
+ assert.dom('.flash-message.alert-critical').exists();
+
+ // Try selecting a single role
+ await click('[data-test-token-roles] tbody tr input');
+ await click('[data-test-token-save]');
+ assert.dom('.flash-message.alert-success').exists();
+
+ await percySnapshot(assert);
+
+ await AccessControl.visitTokens();
+ // Policies cell for our clay token should read "No Policies"
+ const clayToken = server.db.tokens.findBy((t) => t.id === 'cl4y-t0k3n');
+ const clayTokenRow = [...findAll('[data-test-token-row]')].find((row) =>
+ row.textContent.includes(clayToken.name)
+ );
+ const policiesCell = clayTokenRow.querySelector(
+ '[data-test-token-policies]'
+ );
+ assert.dom(policiesCell).exists();
+ assert.dom(policiesCell).hasText('No Policies');
+
+ // Roles cell should have 1 tag
+ const rolesCell = clayTokenRow.querySelector('[data-test-token-roles]');
+ const rolesCellTags = rolesCell
+ .querySelector('.tag-group')
+ .querySelectorAll('span');
+ assert.equal(rolesCellTags.length, 1);
+ });
+ test('Token can be deleted', async function (assert) {
+ const token = server.db.tokens.findBy((t) => t.id === 'cl4y-t0k3n');
+ await visit(`/access-control/tokens/${token.id}`);
+ await click('[data-test-delete-token]');
+ assert.dom('.flash-message.alert-success').exists();
+ await AccessControl.visitTokens();
+ assert.dom('[data-test-token-name="cl4y-t0k3n"]').doesNotExist();
+ });
+ test('New Token creation', async function (assert) {
+ await click('[data-test-create-token]');
+ assert.equal(currentURL(), '/access-control/tokens/new');
+ await fillIn('[data-test-token-name-input]', 'Timeless Token');
+ await click('[data-test-token-save]');
+ assert.dom('.flash-message.alert-success').exists();
+ await AccessControl.visitTokens();
+ assert
+ .dom('[data-test-token-name="Timeless Token"]')
+ .exists({ count: 1 });
+ const newTokenRow = [...findAll('[data-test-token-row]')].find((row) =>
+ row.textContent.includes('Timeless Token')
+ );
+ const newTokenExpirationCell = newTokenRow.querySelector(
+ '[data-test-token-expiration-time]'
+ );
+ assert.dom(newTokenExpirationCell).hasText('Never');
+
+ // Now create one with a TTL
+ await click('[data-test-create-token]');
+ assert.equal(currentURL(), '/access-control/tokens/new');
+ await fillIn('[data-test-token-name-input]', 'TTL Token');
+ // Select the "8 hours" radio within the .expiration-time div
+ await click('.expiration-time input[value="8h"]');
+ await click('[data-test-token-save]');
+ assert.dom('.flash-message.alert-success').exists();
+ await AccessControl.visitTokens();
+ assert.dom('[data-test-token-name="TTL Token"]').exists({ count: 1 });
+ const ttlTokenRow = [...findAll('[data-test-token-row]')].find((row) =>
+ row.textContent.includes('TTL Token')
+ );
+ const ttlTokenExpirationCell = ttlTokenRow.querySelector(
+ '[data-test-token-expiration-time]'
+ );
+ assert.dom(ttlTokenExpirationCell).hasText('in 8 hours');
+
+ // Now create one with an expiration time
+ await click('[data-test-create-token]');
+ assert.equal(currentURL(), '/access-control/tokens/new');
+ await fillIn('[data-test-token-name-input]', 'Expiring Token');
+ // select the Custom radio button
+ await click('.expiration-time input[value="custom"]');
+ assert
+ .dom('[data-test-token-expiration-time-input]')
+ .exists('HTML datetime-local picker exists');
+ await percySnapshot(assert);
+ // select a date/time for 100 minutes into the future in GMT
+ const soon = new Date();
+ soon.setMinutes(soon.getMinutes() + 100);
+ var tzoffset = new Date().getTimezoneOffset() * 60000; //offset in milliseconds
+ var soonString = new Date(soon - tzoffset).toISOString().slice(0, -1);
+ await fillIn('[data-test-token-expiration-time-input]', soonString);
+ await click('[data-test-token-save]');
+ assert.dom('.flash-message.alert-success').exists();
+ await AccessControl.visitTokens();
+ assert
+ .dom('[data-test-token-name="Expiring Token"]')
+ .exists({ count: 1 });
+ const expiringTokenRow = [...findAll('[data-test-token-row]')].find(
+ (row) => row.textContent.includes('Expiring Token')
+ );
+ const expiringTokenExpirationCell = expiringTokenRow.querySelector(
+ '[data-test-token-expiration-time]'
+ );
+ assert
+ .dom(expiringTokenExpirationCell)
+ .hasText('in 2 hours', 'Expiration time is relativized and rounded');
+ });
+ });
});
diff --git a/ui/tests/acceptance/variables-test.js b/ui/tests/acceptance/variables-test.js
index fb38aade0..ce1986d70 100644
--- a/ui/tests/acceptance/variables-test.js
+++ b/ui/tests/acceptance/variables-test.js
@@ -71,7 +71,7 @@ module('Acceptance | variables', function (hooks) {
const variablesToken = server.db.tokens.find(VARIABLE_TOKEN_ID);
window.localStorage.nomadTokenSecret = variablesToken.secretId;
server.db.variables.update({ namespace: 'default' });
- const policy = server.db.policies.find('Variable Maker');
+ const policy = server.db.policies.find('Variable-Maker');
policy.rulesJSON.Namespaces[0].Variables.Paths.find(
(path) => path.PathSpec === '*'
).Capabilities = ['list', 'read', 'destroy'];
@@ -452,7 +452,7 @@ module('Acceptance | variables', function (hooks) {
server.createList('variable', 3);
const variablesToken = server.db.tokens.find(VARIABLE_TOKEN_ID);
window.localStorage.nomadTokenSecret = variablesToken.secretId;
- const policy = server.db.policies.find('Variable Maker');
+ const policy = server.db.policies.find('Variable-Maker');
policy.rulesJSON.Namespaces[0].Variables.Paths.find(
(path) => path.PathSpec === '*'
).Capabilities = ['list'];
@@ -580,7 +580,7 @@ module('Acceptance | variables', function (hooks) {
server.createList('variable', 3);
const variablesToken = server.db.tokens.find(VARIABLE_TOKEN_ID);
window.localStorage.nomadTokenSecret = variablesToken.secretId;
- const policy = server.db.policies.find('Variable Maker');
+ const policy = server.db.policies.find('Variable-Maker');
policy.rulesJSON.Namespaces[0].Variables.Paths.find(
(path) => path.PathSpec === '*'
).Capabilities = ['list', 'read', 'write'];
@@ -634,7 +634,7 @@ module('Acceptance | variables', function (hooks) {
server.createList('variable', 3);
const variablesToken = server.db.tokens.find(VARIABLE_TOKEN_ID);
window.localStorage.nomadTokenSecret = variablesToken.secretId;
- const policy = server.db.policies.find('Variable Maker');
+ const policy = server.db.policies.find('Variable-Maker');
policy.rulesJSON.Namespaces[0].Variables.Paths.find(
(path) => path.PathSpec === '*'
).Capabilities = ['list', 'read'];
@@ -763,7 +763,7 @@ module('Acceptance | variables', function (hooks) {
server.createList('variable', 3);
const variablesToken = server.db.tokens.find(VARIABLE_TOKEN_ID);
window.localStorage.nomadTokenSecret = variablesToken.secretId;
- const policy = server.db.policies.find('Variable Maker');
+ const policy = server.db.policies.find('Variable-Maker');
policy.rulesJSON.Namespaces[0].Variables.Paths.find(
(path) => path.PathSpec === '*'
).Capabilities = ['list', 'read', 'destroy'];
@@ -799,7 +799,7 @@ module('Acceptance | variables', function (hooks) {
server.createList('variable', 3);
const variablesToken = server.db.tokens.find(VARIABLE_TOKEN_ID);
window.localStorage.nomadTokenSecret = variablesToken.secretId;
- const policy = server.db.policies.find('Variable Maker');
+ const policy = server.db.policies.find('Variable-Maker');
policy.rulesJSON.Namespaces[0].Variables.Paths.find(
(path) => path.PathSpec === '*'
).Capabilities = ['list', 'read'];
diff --git a/ui/tests/pages/access-control.js b/ui/tests/pages/access-control.js
new file mode 100644
index 000000000..932127cc1
--- /dev/null
+++ b/ui/tests/pages/access-control.js
@@ -0,0 +1,13 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: BUSL-1.1
+ */
+
+import { create, visitable } from 'ember-cli-page-object';
+
+export default create({
+ visit: visitable('/access-control'),
+ visitTokens: visitable('/access-control/tokens'),
+ visitPolicies: visitable('/access-control/policies'),
+ visitRoles: visitable('/access-control/roles'),
+});
diff --git a/ui/yarn.lock b/ui/yarn.lock
index 8e5b4ffd7..73b8345ba 100644
--- a/ui/yarn.lock
+++ b/ui/yarn.lock
@@ -2840,6 +2840,16 @@
ember-cli-version-checker "^5.1.2"
semver "^7.3.5"
+"@ember/test-waiters@^3.0.2":
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/@ember/test-waiters/-/test-waiters-3.0.2.tgz#5b950c580a1891ed1d4ee64f9c6bacf49a15ea6f"
+ integrity sha512-H8Q3Xy9rlqhDKnQpwt2pzAYDouww4TZIGSI1pZJhM7mQIGufQKuB0ijzn/yugA6Z+bNdjYp1HioP8Y4hn2zazQ==
+ dependencies:
+ calculate-cache-key-for-tree "^2.0.0"
+ ember-cli-babel "^7.26.6"
+ ember-cli-version-checker "^5.1.2"
+ semver "^7.3.5"
+
"@embroider/addon-shim@^1.0.0", "@embroider/addon-shim@^1.2.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@embroider/addon-shim/-/addon-shim-1.5.0.tgz#639b8b394336a5ae26dd3e24ffc3d34d864ac5ce"
@@ -2848,7 +2858,7 @@
"@embroider/shared-internals" "^1.5.0"
semver "^7.3.5"
-"@embroider/addon-shim@^1.5.0", "@embroider/addon-shim@^1.8.4":
+"@embroider/addon-shim@^1.8.4":
version "1.8.4"
resolved "https://registry.yarnpkg.com/@embroider/addon-shim/-/addon-shim-1.8.4.tgz#0e7f32c5506bf0f3eb0840506e31c36c7053763c"
integrity sha512-sFhfWC0vI18KxVenmswQ/ShIvBg4juL8ubI+Q3NTSdkCTeaPQ/DIOUF6oR5DCQ8eO/TkIaw+kdG3FkTY6yNJqA==
@@ -3432,35 +3442,36 @@
resolved "https://registry.yarnpkg.com/@handlebars/parser/-/parser-1.1.0.tgz#d6dbc7574774b238114582410e8fee0dc3532bdf"
integrity sha512-rR7tJoSwJ2eooOpYGxGGW95sLq6GXUaS1UtWvN7pei6n2/okYvCGld9vsUTvkl2migxbkszsycwtMf/GEc1k1A==
-"@hashicorp/design-system-components@^2.6.0":
- version "2.6.0"
- resolved "https://registry.yarnpkg.com/@hashicorp/design-system-components/-/design-system-components-2.6.0.tgz#578cfed9f05d659c49b1bb23093d5df81b600200"
- integrity sha512-mfCTc3JuNME0pVUxxdrcGjFVRnHtkacWEJZyTUByYaM6lerxXQzztuVTEI/eDhH594ytGjLjoPhRm85YYfoGuA==
+"@hashicorp/design-system-components@^2.12.0":
+ version "2.12.0"
+ resolved "https://registry.yarnpkg.com/@hashicorp/design-system-components/-/design-system-components-2.12.0.tgz#295b910c1673d7f2c8bc62be9f07c585788f2df3"
+ integrity sha512-ewUWfyavTRVVcwKHigICdEIcQeDwLYXt4S/m+xGKyjowRGTfOIcU6tvg5eIB0YVtbhyA1AHEQi80+NhK867Kvg==
dependencies:
"@ember/render-modifiers" "^2.0.5"
- "@hashicorp/design-system-tokens" "^1.5.0"
- "@hashicorp/ember-flight-icons" "^3.0.4"
+ "@ember/test-waiters" "^3.0.2"
+ "@hashicorp/design-system-tokens" "^1.8.0"
+ "@hashicorp/ember-flight-icons" "^3.1.2"
dialog-polyfill "^0.5.6"
ember-a11y-refocus "^3.0.2"
- ember-auto-import "^2.6.0"
+ ember-auto-import "^2.6.3"
ember-cached-decorator-polyfill "^0.1.4"
ember-cli-babel "^7.26.11"
+ ember-cli-clipboard "^1.0.0"
ember-cli-htmlbars "^6.2.0"
ember-cli-sass "^10.0.1"
ember-composable-helpers "^4.5.0"
- ember-focus-trap "^1.0.1"
- ember-keyboard "^8.1.0"
- ember-named-blocks-polyfill "^0.2.5"
+ ember-focus-trap "^1.0.2"
+ ember-keyboard "^8.2.0"
ember-stargate "^0.4.3"
- ember-style-modifier "^0.8.0"
- ember-truth-helpers "^3.0.0"
- sass "^1.58.3"
+ ember-style-modifier "^3.0.1"
+ ember-truth-helpers "^3.1.1"
+ sass "^1.62.1"
tippy.js "^6.3.7"
-"@hashicorp/design-system-tokens@^1.5.0":
- version "1.5.0"
- resolved "https://registry.yarnpkg.com/@hashicorp/design-system-tokens/-/design-system-tokens-1.5.0.tgz#e2a5ff96ed4e8b03f3b3258e93ef5eb115479402"
- integrity sha512-Th/UOl73XZsPG7ypBrgVR7ZSKV9gfES1nC/E5kqEN0AOSBhlX2JaE2kFFprPYoe+zwaJ6FjASztWKBSK2h7+0A==
+"@hashicorp/design-system-tokens@^1.8.0":
+ version "1.8.0"
+ resolved "https://registry.yarnpkg.com/@hashicorp/design-system-tokens/-/design-system-tokens-1.8.0.tgz#8734bc46fbdaf72b694927ba7352694d0da3e8e1"
+ integrity sha512-miRHSodtBJ0mkBkRpppW857U79lk2vIwNTv7bPmIbX1SQJONFsWQaOXJOKGAHEAxxWpGn0M98xnqo3Eol9Y6Eg==
"@hashicorp/ember-flight-icons@^3.0.4":
version "3.0.4"
@@ -3472,11 +3483,26 @@
ember-cli-babel "^7.26.11"
ember-cli-htmlbars "^6.1.0"
+"@hashicorp/ember-flight-icons@^3.1.2":
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/@hashicorp/ember-flight-icons/-/ember-flight-icons-3.1.2.tgz#39982cae2d1ba6a25cd3f8aa336cae674b2c463c"
+ integrity sha512-aroJ4xd/+6/HTTJnK7KCGNh77Eei8aDpQquEeqBkTT+TK5+C8y043J8joTUAOXq1brIaOwGtd9WuWPpJbb/Csw==
+ dependencies:
+ "@hashicorp/flight-icons" "^2.19.0"
+ ember-auto-import "^2.6.3"
+ ember-cli-babel "^7.26.11"
+ ember-cli-htmlbars "^6.2.0"
+
"@hashicorp/flight-icons@^2.13.0":
version "2.13.0"
resolved "https://registry.yarnpkg.com/@hashicorp/flight-icons/-/flight-icons-2.13.0.tgz#5ffa5edc3aa96e8574e57ed8ff049ac652febca0"
integrity sha512-nWZ20v+r3c35OOUMhV+BdT34AHwqNELB59ZcnWaElqbJ4nkppQA9Xr/bT/wGx1yhftwZaDtpRayBWdJCT9zy6g==
+"@hashicorp/flight-icons@^2.19.0":
+ version "2.19.0"
+ resolved "https://registry.yarnpkg.com/@hashicorp/flight-icons/-/flight-icons-2.19.0.tgz#4088574887232bb50a3c1d6e5044456c90b88e40"
+ integrity sha512-FzEHAOLSQMS5yJorF5H3xP4BKfpIUFRnQgkFl6i1RmvwpOJQgeoz9w/QqWvjh+H/DhFomeC6OxHGgD6rZL7phw==
+
"@hashicorp/structure-icons@^1.3.0":
version "1.9.2"
resolved "https://registry.yarnpkg.com/@hashicorp/structure-icons/-/structure-icons-1.9.2.tgz#c75f955b2eec414ecb92f3926c79b4ca01731d3c"
@@ -10133,7 +10159,7 @@ ember-assign-helper@^0.3.0:
ember-cli-babel "^7.19.0"
ember-cli-htmlbars "^4.3.1"
-ember-auto-import@^1.10.1, ember-auto-import@^1.11.3, ember-auto-import@^1.2.19, ember-auto-import@^1.6.0, ember-auto-import@^2.2.4, ember-auto-import@^2.4.0, ember-auto-import@^2.4.2, ember-auto-import@^2.6.0:
+ember-auto-import@^1.10.1, ember-auto-import@^1.11.3, ember-auto-import@^1.2.19, ember-auto-import@^1.6.0, ember-auto-import@^2.2.4, ember-auto-import@^2.4.0, ember-auto-import@^2.4.2, ember-auto-import@^2.5.0, ember-auto-import@^2.6.0, ember-auto-import@^2.6.3:
version "2.4.0"
resolved "https://registry.yarnpkg.com/ember-auto-import/-/ember-auto-import-2.4.0.tgz#91c4797f08315728086e35af954cb60bd23c14bc"
integrity sha512-BwF6iTaoSmT2vJ9NEHEGRBCh2+qp+Nlaz/Q7roqNSxl5oL5iMRwenPnHhOoBPTYZvPhcV/KgXR5e+pBQ107plQ==
@@ -10881,14 +10907,14 @@ ember-cli@~3.28.5:
workerpool "^6.1.4"
yam "^1.0.0"
-ember-click-outside@^3.0.0:
- version "3.0.0"
- resolved "https://registry.yarnpkg.com/ember-click-outside/-/ember-click-outside-3.0.0.tgz#a7271345c5960b5dfe1e45a7f7245d1cf8f383dc"
- integrity sha512-X2hLE9Set/tQ9KAEUxfGzCTUgJu/g2sKG+t2ghk/EDz8zF+Y/DPtlxeyZTR6NEPsUbzu3Pqe9gWJUxwaiXC0wg==
+ember-click-outside@^5.0.0:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/ember-click-outside/-/ember-click-outside-5.0.1.tgz#e7df866cf03d940c73741effa0766e175213c7e3"
+ integrity sha512-RilHTCQvD/5d9pZf6H7MbmBWlVl68nhvn1BPLtfpt9iCNyhtnh5SgwIWGHkJRuTz+DooN6hqTe4Wmq8Zk6kYDw==
dependencies:
ember-cli-babel "^7.26.6"
ember-cli-htmlbars "^5.7.1"
- ember-modifier "^2.1.0 || ^3.0.0"
+ ember-modifier "^3.2.0"
ember-compatibility-helpers@^1.1.1, ember-compatibility-helpers@^1.1.2, ember-compatibility-helpers@^1.2.5:
version "1.2.6"
@@ -11076,10 +11102,10 @@ ember-fetch@^8.1.1:
node-fetch "^2.6.1"
whatwg-fetch "^3.6.2"
-ember-focus-trap@^1.0.1:
- version "1.0.1"
- resolved "https://registry.yarnpkg.com/ember-focus-trap/-/ember-focus-trap-1.0.1.tgz#a99565f6ce55d500b92a0965e79e3ad04219f157"
- integrity sha512-ZUyq5ZkIuXp+ng9rCMkqBh36/V95PltL7iljStkma4+651xlAy3Z84L9WOu/uOJyVpNUxii8RJBbAySHV6c+RQ==
+ember-focus-trap@^1.0.2:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/ember-focus-trap/-/ember-focus-trap-1.1.0.tgz#e3c47c6e916e838af3884b43e2794e87088d2bac"
+ integrity sha512-KxbCKpAJaBVZm+bW4tHPoBJAZThmxa6pI+WQusL+bj0RtAnGUNkWsVy6UBMZ5QqTQzf4EvGHkCVACVp5lbAWMQ==
dependencies:
"@embroider/addon-shim" "^1.0.0"
focus-trap "^6.7.1"
@@ -11141,12 +11167,12 @@ ember-inline-svg@^1.0.1:
svgo "~1.2.2"
walk-sync "~2.0.2"
-ember-keyboard@^8.1.0:
- version "8.2.0"
- resolved "https://registry.yarnpkg.com/ember-keyboard/-/ember-keyboard-8.2.0.tgz#d11fa7f0443606b7c1850bbd8253274a00046e11"
- integrity sha512-h2kuS2irtIyvNbAMkGDlDTB4TPXwgmC6Nu9bIuGWoCjkGdgJbUg0VegfyRJ1TlxbIHlAelbqVpE8UhfgY5wEag==
+ember-keyboard@^8.2.0:
+ version "8.2.1"
+ resolved "https://registry.yarnpkg.com/ember-keyboard/-/ember-keyboard-8.2.1.tgz#945a8a71068d81c06ad26851008ef81061db2a59"
+ integrity sha512-wT9xpt3GKsiodGZoifKU4OyeRjXWlmKV9ZHHsp6wJBwMFpl4wWPjTNdINxivk2qg/WFNIh8nUiwuG4+soWXPdw==
dependencies:
- "@embroider/addon-shim" "^1.5.0"
+ "@embroider/addon-shim" "^1.8.4"
ember-destroyable-polyfill "^2.0.3"
ember-modifier "^2.1.2 || ^3.1.0 || ^4.0.0"
ember-modifier-manager-polyfill "^1.2.0"
@@ -11188,7 +11214,7 @@ ember-modifier-manager-polyfill@^1.2.0:
ember-cli-version-checker "^2.1.2"
ember-compatibility-helpers "^1.2.0"
-ember-modifier@3.2.7, "ember-modifier@^2.1.0 || ^3.0.0", ember-modifier@^3.0.0, ember-modifier@^3.2.7:
+ember-modifier@3.2.7, ember-modifier@^3.0.0, ember-modifier@^3.2.0, ember-modifier@^3.2.7:
version "3.2.7"
resolved "https://registry.yarnpkg.com/ember-modifier/-/ember-modifier-3.2.7.tgz#f2d35b7c867cbfc549e1acd8d8903c5ecd02ea4b"
integrity sha512-ezcPQhH8jUfcJQbbHji4/ZG/h0yyj1jRDknfYue/ypQS8fM8LrGcCMo0rjDZLzL1Vd11InjNs3BD7BdxFlzGoA==
@@ -11208,6 +11234,15 @@ ember-modifier@3.2.7, "ember-modifier@^2.1.0 || ^3.0.0", ember-modifier@^3.0.0,
ember-cli-normalize-entity-name "^1.0.0"
ember-cli-string-utils "^1.1.0"
+"ember-modifier@^3.2.7 || ^4.0.0":
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/ember-modifier/-/ember-modifier-4.1.0.tgz#cb91efbf8ca4ff4a1a859767afa42dddba5a2bbd"
+ integrity sha512-YFCNpEYj6jdyy3EjslRb2ehNiDvaOrXTilR9+ngq+iUqSHYto2zKV0rleiA1XJQ27ELM1q8RihT29U6Lq5EyqQ==
+ dependencies:
+ "@embroider/addon-shim" "^1.8.4"
+ ember-cli-normalize-entity-name "^1.0.0"
+ ember-cli-string-utils "^1.1.0"
+
ember-moment@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/ember-moment/-/ember-moment-9.0.1.tgz#fcf06cb8ef07c8d0108820c1639778590d613b38"
@@ -11219,14 +11254,6 @@ ember-moment@^9.0.1:
moment "^2.29.1"
moment-timezone "^0.5.33"
-ember-named-blocks-polyfill@^0.2.5:
- version "0.2.5"
- resolved "https://registry.yarnpkg.com/ember-named-blocks-polyfill/-/ember-named-blocks-polyfill-0.2.5.tgz#d5841406277026a221f479c815cfbac6cdcaeecb"
- integrity sha512-OVMxzkfqJrEvmiky7gFzmuTaImCGm7DOudHWTdMBPO7E+dQSunrcRsJMgO9ZZ56suqBIz/yXbEURrmGS+avHxA==
- dependencies:
- ember-cli-babel "^7.19.0"
- ember-cli-version-checker "^5.1.1"
-
ember-on-resize-modifier@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/ember-on-resize-modifier/-/ember-on-resize-modifier-1.0.0.tgz#b4e12dc023b4d608d7b0f4fa0100722fb860cdd4"
@@ -11447,13 +11474,14 @@ ember-style-modifier@^0.7.0:
ember-cli-babel "^7.26.6"
ember-modifier "^3.0.0"
-ember-style-modifier@^0.8.0:
- version "0.8.0"
- resolved "https://registry.yarnpkg.com/ember-style-modifier/-/ember-style-modifier-0.8.0.tgz#ef46b3f288e63e3d850418ea8dc6f7b12edde721"
- integrity sha512-I7M+oZ+poYYOP7n521rYv7kkYZbxotL8VbtHYxLQ3tasRZYQJ21qfu3vVjydSjwyE3w7EZRgKngBoMhKSAEZnw==
+ember-style-modifier@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/ember-style-modifier/-/ember-style-modifier-3.0.1.tgz#96aaaa2b713108725b81d8b934ec445ece6b89c3"
+ integrity sha512-WHRVIiqY/dpwDtVWlnHW0P4Z+Jha8QEwfaQdIF2ckJL77ZKdjbV2j1XZymS0Nzj61EGx5BM+YEsGL16r3hLv2A==
dependencies:
- ember-cli-babel "^7.26.6"
- ember-modifier "^3.2.7"
+ ember-auto-import "^2.5.0"
+ ember-cli-babel "^7.26.11"
+ ember-modifier "^3.2.7 || ^4.0.0"
ember-template-lint@^3.15.0:
version "3.16.0"
@@ -11525,6 +11553,13 @@ ember-tracked-storage-polyfill@1.0.0:
dependencies:
ember-cli-babel "^7.22.1"
+ember-truth-helpers@^3.1.1:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/ember-truth-helpers/-/ember-truth-helpers-3.1.1.tgz#434715926d72bcc63b8a115dec09745fda4474dc"
+ integrity sha512-FHwJAx77aA5q27EhdaaiBFuy9No+8yaWNT5A7zs0sIFCmf14GbcLn69vJEp6mW7vkITezizGAWhw7gL0Wbk7DA==
+ dependencies:
+ ember-cli-babel "^7.22.1"
+
"ember-usable@https://github.com/pzuraq/ember-usable#0d03a50":
version "0.0.0"
resolved "https://github.com/pzuraq/ember-usable#0d03a500a2f49041a4ddff0bb05b077c3907ed7d"
@@ -18638,10 +18673,10 @@ sass@^1.17.3:
dependencies:
chokidar ">=3.0.0 <4.0.0"
-sass@^1.58.3:
- version "1.63.3"
- resolved "https://registry.yarnpkg.com/sass/-/sass-1.63.3.tgz#527746aa43bf2e4eac1ab424f67f6f18a081061a"
- integrity sha512-ySdXN+DVpfwq49jG1+hmtDslYqpS7SkOR5GpF6o2bmb1RL/xS+wvPmegMvMywyfsmAV6p7TgwXYGrCZIFFbAHg==
+sass@^1.62.1:
+ version "1.67.0"
+ resolved "https://registry.yarnpkg.com/sass/-/sass-1.67.0.tgz#fed84d74b9cd708db603b1380d6dc1f71bb24f6f"
+ integrity sha512-SVrO9ZeX/QQyEGtuZYCVxoeAL5vGlYjJ9p4i4HFuekWl8y/LtJ7tJc10Z+ck1c8xOuoBm2MYzcLfTAffD0pl/A==
dependencies:
chokidar ">=3.0.0 <4.0.0"
immutable "^4.0.0"
diff --git a/website/content/api-docs/acl/roles.mdx b/website/content/api-docs/acl/roles.mdx
index d99aa9efa..7e2e2ca58 100644
--- a/website/content/api-docs/acl/roles.mdx
+++ b/website/content/api-docs/acl/roles.mdx
@@ -79,7 +79,7 @@ $ curl \
}
```
-## Update Token
+## Update Role
This endpoint updates an existing ACL Role. The request is always forwarded to the
authoritative region.