Create a Custom Role
Combine two permission policies into a custom role, assign it to a user, and validate the resulting access boundary.
Applies to:API7 Gateway 3.10.x
Roles:Platform engineer
Goal
A custom role combining a read-only policy on one gateway group and a full-access policy on another, assigned to a non-root user without disturbing any role the target user already held.
This guide builds one example role; for a role model that spans many teams and environments, see Design a Custom Role System.
Before you begin
- You have a Dashboard token for a user that can manage roles and permission policies.
- You have at least two gateway groups, such as
productionandtest. This guide grants read-only access to one and full access to the other. - You have a non-root user to assign the resulting role to. See User Management if you need to invite one first.
For the complete policy JSON syntax and the full catalog of actions and resources, see Permission Policies and Boundaries and Permission Policy Actions and Resources.
Set the variables this guide uses:
export API7_ADMIN_URL="https://localhost:7443"
export ORIGINAL_ROLE_IDS_FILE="create-custom-role-original-role-ids.json"
read -rp "Production gateway group ID: " PRODUCTION_GATEWAY_GROUP_ID && export PRODUCTION_GATEWAY_GROUP_ID
read -rp "Test gateway group ID: " TEST_GATEWAY_GROUP_ID && export TEST_GATEWAY_GROUP_ID
read -rsp "Dashboard token: " API7_ADMIN_KEY && export API7_ADMIN_KEY && echoThen check each prerequisite state:
-
The local backup file used before replacing the target user's roles does not already exist:
test ! -e "${ORIGINAL_ROLE_IDS_FILE}" || { echo "Stop: ${ORIGINAL_ROLE_IDS_FILE} already exists"; exit 1; }Expected: no output. If the file exists, inspect whether it is an unfinished run's role backup before moving it or completing that run's cleanup.
-
The control plane is reachable and your token is valid. Check it with:
curl -ksS --connect-timeout 3 --max-time 10 -o /dev/null -w '%{http_code}\n' "${API7_ADMIN_URL}/api/gateway_groups" -H "X-API-KEY: ${API7_ADMIN_KEY}"Expected:
200. -
Both gateway groups exist. Check each with:
curl -ksS --connect-timeout 3 --max-time 10 -o /dev/null -w 'production: %{http_code}\n' "${API7_ADMIN_URL}/api/gateway_groups/${PRODUCTION_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}" curl -ksS --connect-timeout 3 --max-time 10 -o /dev/null -w 'test: %{http_code}\n' "${API7_ADMIN_URL}/api/gateway_groups/${TEST_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"Expected:
production: 200andtest: 200. -
The target user exists. Look up their ID by username or email:
read -rp "Username or email to search for: " TARGET_USER_SEARCH curl -ksS --connect-timeout 3 --max-time 10 "${API7_ADMIN_URL}/api/users?search=${TARGET_USER_SEARCH}&page=1&page_size=100" \ -H "X-API-KEY: ${API7_ADMIN_KEY}" | jq -r '.list[] | "\(.id)\t\(.username)\t\(.email)"' read -rp "Target user ID from the row above: " TARGET_USER_ID && export TARGET_USER_IDExpected: exactly one row for the intended user.
-
No leftover object from an earlier run of this guide exists. Roles and permission policies get a server-generated ID, so this checks by name and label instead of by a fixed ID:
for policy_name in create-custom-role-production-readonly create-custom-role-test-full-access; do curl -ksS --connect-timeout 3 --max-time 10 "${API7_ADMIN_URL}/api/permission_policies?search=${policy_name}&page=1&page_size=100" \ -H "X-API-KEY: ${API7_ADMIN_KEY}" \ | jq -r --arg name "${policy_name}" \ '.list[] | select(.name == $name and .labels.guide == "create-custom-role") | "permission policy\t\(.id)\t\(.name)"' done curl -ksS --connect-timeout 3 --max-time 10 "${API7_ADMIN_URL}/api/roles?search=create-custom-role-development-team-member&page=1&page_size=100" \ -H "X-API-KEY: ${API7_ADMIN_KEY}" \ | jq -r '.list[] | select(.name == "create-custom-role-development-team-member" and .labels.guide == "create-custom-role") | "role\t\(.id)\t\(.name)"'Expected: no output. Any row identifies an object left by an earlier run, including the ID needed to inspect or delete it. Clean up that object before continuing; do not delete an object until you have confirmed it belongs to the unfinished run.
Steps
Four steps: two permission policies, the role that combines them, and the target user who receives it.
Step 1: Create the production read-only permission policy
Grant read-only access to the production gateway group and its services.
<.*>Get<.*> is not a safe definition of read-only. It is a regular expression over action names, and gateway:GetAdminKey matches it. That action returns the gateway group's Admin API key, which is a write credential — a holder can configure routes, services and plugins on the data plane directly, bypassing every policy you wrote.
The policy below therefore denies it explicitly. If you adapt this pattern, either enumerate the actions you mean or keep the deny, and audit any other Get-named action that returns a credential rather than a configuration.
-
In the API7 Dashboard, go to Organization -> Permission Policies.
-
Click Create Permission Policy.
-
Enter the name
create-custom-role-production-readonly. -
In the policy editor, paste the policy below, replacing
{production_gateway_group_id}with the ID you set asPRODUCTION_GATEWAY_GROUP_ID:{ "statement": [ { "effect": "allow", "resources": ["arn:api7:gateway:gatewaygroup/{production_gateway_group_id}"], "actions": ["<.*>Get<.*>"] }, { "effect": "allow", "resources": ["arn:api7:gateway:gatewaygroup/{production_gateway_group_id}/service/<.*>"], "actions": ["<.*>Get<.*>"] }, { "effect": "deny", "resources": ["arn:api7:gateway:gatewaygroup/{production_gateway_group_id}"], "actions": ["gateway:GetAdminKey"] } ] }The third statement is the guardrail from the warning above. A
denybeats everyallow, so it holds even though the second statement's pattern also matches that action. -
Save the policy.
PRODUCTION_POLICY_RESPONSE=$(jq -n --arg ggid "${PRODUCTION_GATEWAY_GROUP_ID}" '{
name: "create-custom-role-production-readonly",
desc: "Read-only access to the production gateway group and its services",
labels: {guide: "create-custom-role"},
policy_document: {
statement: [
{effect: "allow", resources: ["arn:api7:gateway:gatewaygroup/" + $ggid], actions: ["<.*>Get<.*>"]},
{effect: "allow", resources: ["arn:api7:gateway:gatewaygroup/" + $ggid + "/service/<.*>"], actions: ["<.*>Get<.*>"]},
{effect: "deny", resources: ["arn:api7:gateway:gatewaygroup/" + $ggid], actions: ["gateway:GetAdminKey"]}
]
}
}' | curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/api/permission_policies" -X POST \
-H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" --data-binary @-)
PRODUCTION_POLICY_ID=$(jq -r '.value.id // empty' <<<"${PRODUCTION_POLICY_RESPONSE}")
test -n "${PRODUCTION_POLICY_ID}" || { jq . <<<"${PRODUCTION_POLICY_RESPONSE}"; echo "Failed to create create-custom-role-production-readonly"; exit 1; }
jq '{id: .value.id, name: .value.name}' <<<"${PRODUCTION_POLICY_RESPONSE}"{ "id": "<generated-id>", "name": "create-custom-role-production-readonly" }What changed: a permission policy exists that allows read (<.*>Get<.*>) actions on the production gateway group and its services, and explicitly denies gateway:GetAdminKey, which that pattern would otherwise have granted.
Step 2: Create the test full-access permission policy
Grant full access to the test gateway group and its services.
-
In Organization -> Permission Policies, click Create Permission Policy again.
-
Enter the name
create-custom-role-test-full-access. -
Paste the policy below, replacing
{test_gateway_group_id}with the ID you set asTEST_GATEWAY_GROUP_ID:{ "statement": [ { "effect": "allow", "resources": ["arn:api7:gateway:gatewaygroup/{test_gateway_group_id}"], "actions": ["<.*>"] }, { "effect": "allow", "resources": ["arn:api7:gateway:gatewaygroup/{test_gateway_group_id}/service/<.*>"], "actions": ["<.*>"] } ] } -
Save the policy.
TEST_POLICY_RESPONSE=$(jq -n --arg ggid "${TEST_GATEWAY_GROUP_ID}" '{
name: "create-custom-role-test-full-access",
desc: "Full access to the test gateway group and its services",
labels: {guide: "create-custom-role"},
policy_document: {
statement: [
{effect: "allow", resources: ["arn:api7:gateway:gatewaygroup/" + $ggid], actions: ["<.*>"]},
{effect: "allow", resources: ["arn:api7:gateway:gatewaygroup/" + $ggid + "/service/<.*>"], actions: ["<.*>"]}
]
}
}' | curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/api/permission_policies" -X POST \
-H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" --data-binary @-)
TEST_POLICY_ID=$(jq -r '.value.id // empty' <<<"${TEST_POLICY_RESPONSE}")
test -n "${TEST_POLICY_ID}" || { jq . <<<"${TEST_POLICY_RESPONSE}"; echo "Failed to create create-custom-role-test-full-access"; exit 1; }
jq '{id: .value.id, name: .value.name}' <<<"${TEST_POLICY_RESPONSE}"{ "id": "<generated-id>", "name": "create-custom-role-test-full-access" }What changed: a second permission policy exists that allows every action on the test gateway group and its services.
Step 3: Create the role and attach both policies
Combine the two policies into one role a team member can be assigned.
- Go to Organization -> Roles.
- Click Create Role.
- Enter the name
create-custom-role-development-team-member. - Add the description
Read-only access to production and full access to test. - Attach the
create-custom-role-production-readonlyandcreate-custom-role-test-full-accesspolicies. - Save the role.
POST /api/roles accepts the attached policy IDs directly, so the role is created already combining both policies:
ROLE_RESPONSE=$(jq -n --arg prod "${PRODUCTION_POLICY_ID}" --arg test "${TEST_POLICY_ID}" '{
name: "create-custom-role-development-team-member",
desc: "Read-only access to production and full access to test",
labels: {guide: "create-custom-role"},
policies: [$prod, $test]
}' | curl -ksS --connect-timeout 3 --max-time 15 "${API7_ADMIN_URL}/api/roles" -X POST \
-H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" --data-binary @-)
ROLE_ID=$(jq -r '.value.id // empty' <<<"${ROLE_RESPONSE}")
test -n "${ROLE_ID}" || { jq . <<<"${ROLE_RESPONSE}"; echo "Failed to create create-custom-role-development-team-member"; exit 1; }
jq '{id: .value.id, name: .value.name, policies: .value.policies}' <<<"${ROLE_RESPONSE}"{ "id": "<generated-id>", "name": "create-custom-role-development-team-member", "policies": ["<production-policy-id>", "<test-policy-id>"] }What changed: a custom role exists that combines both policies; it is not yet assigned to anyone.
Step 4: Assign the role to the target user
PUT /api/users/{user_id}/assigned_roles replaces the target user's complete list of assigned roles, so read their current roles first and include them alongside the new one — sending only the new role's ID would unassign every role they already had.
- Go to Organization -> Users.
- Select the target user found in Before you begin.
- Attach the
create-custom-role-development-team-memberrole, leaving any roles the target user already has in place. - Save the change.
ORIGINAL_ROLE_IDS=$(curl -ksS --connect-timeout 3 --max-time 10 "${API7_ADMIN_URL}/api/users/${TARGET_USER_ID}" \
-H "X-API-KEY: ${API7_ADMIN_KEY}" | jq -ce '[(.value.roles // [])[] | .id]') \
|| { echo "Could not read the target user's current roles"; exit 1; }
printf '%s\n' "${ORIGINAL_ROLE_IDS}" >"${ORIGINAL_ROLE_IDS_FILE}"
STATUS=$(jq -n --argjson original "${ORIGINAL_ROLE_IDS}" --arg role "${ROLE_ID}" '{roles: (($original + [$role]) | unique)}' \
| curl -ksS --connect-timeout 3 --max-time 15 -o /dev/null -w '%{http_code}' "${API7_ADMIN_URL}/api/users/${TARGET_USER_ID}/assigned_roles" -X PUT \
-H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" --data-binary @-)
test "${STATUS}" = 200 || { echo "Expected 200, got ${STATUS}"; exit 1; }What changed: the target user now holds create-custom-role-development-team-member in addition to every role saved in ORIGINAL_ROLE_IDS_FILE. Keep that file until cleanup succeeds.
Validate
Read both objects back independently of the write responses above, and confirm the role holds exactly the two policies and the target user holds the new role.
- In Organization -> Roles, open
create-custom-role-development-team-memberand confirm it contains onlycreate-custom-role-production-readonlyandcreate-custom-role-test-full-access. - In Organization -> Users, open the target user and confirm the new role appears alongside every role that was present before this guide.
curl -ksS --connect-timeout 3 --max-time 10 "${API7_ADMIN_URL}/api/roles/${ROLE_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}" \
| jq -e --arg prod "${PRODUCTION_POLICY_ID}" --arg test "${TEST_POLICY_ID}" \
'(.value.policies | sort) == ([$prod, $test] | sort)' >/dev/null
curl -ksS --connect-timeout 3 --max-time 10 "${API7_ADMIN_URL}/api/users/${TARGET_USER_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}" \
| jq -e --arg role "${ROLE_ID}" '[(.value.roles // [])[] | .id] | any(. == $role)' >/dev/nullExpected: both jq -e checks exit 0 — the role's policies array is exactly the two IDs created above, and the target user's roles array includes the new role's ID.
This confirms the grant is recorded correctly. To confirm it is enforced, sign in as the assigned user (or use a token that user creates in the Dashboard) and repeat the same read and write against both gateway groups. The production read and both test operations must succeed. The production write must reach the control plane and return an explicit authorization-denial response.
The checked-in Admin API reference does not document an exact status code for a permission-policy denial, so record the status and response body rather than asserting a particular code. A timeout, TLS or DNS failure, invalid token (401), or server error (5xx) is inconclusive and must not be counted as proof that the role blocked the write.
Roll back / Clean up
Restore the target user's original roles first, then delete the role, then delete both policies once nothing references them — deleting an object that is still referenced is rejected.
- In Organization -> Users, open the target user and remove only
create-custom-role-development-team-member. Confirm every role that predated this guide remains assigned. - In Organization -> Roles, delete
create-custom-role-development-team-member. - In Organization -> Permission Policies, confirm neither policy is referenced by another role, then delete
create-custom-role-production-readonlyandcreate-custom-role-test-full-access.
test -f "${ORIGINAL_ROLE_IDS_FILE}" || { echo "Missing original-role backup: ${ORIGINAL_ROLE_IDS_FILE}"; exit 1; }
jq -e 'type == "array"' "${ORIGINAL_ROLE_IDS_FILE}" >/dev/null || { echo "Invalid original-role backup"; exit 1; }
RESTORE_STATUS=$(jq -n --slurpfile original "${ORIGINAL_ROLE_IDS_FILE}" '{roles: $original[0]}' \
| curl -ksS --connect-timeout 3 --max-time 15 -o /dev/null -w '%{http_code}' "${API7_ADMIN_URL}/api/users/${TARGET_USER_ID}/assigned_roles" -X PUT \
-H "X-API-KEY: ${API7_ADMIN_KEY}" -H "Content-Type: application/json" --data-binary @-)
test "${RESTORE_STATUS}" = 200 || { echo "Could not restore original roles: ${RESTORE_STATUS}"; exit 1; }
ROLE_DELETE_STATUS=$(curl -ksS --connect-timeout 3 --max-time 15 -o /dev/null -w '%{http_code}' "${API7_ADMIN_URL}/api/roles/${ROLE_ID}" -X DELETE -H "X-API-KEY: ${API7_ADMIN_KEY}")
test "${ROLE_DELETE_STATUS}" = 200 || { echo "Could not delete role: ${ROLE_DELETE_STATUS}"; exit 1; }Expected: no output. The target user has their pre-guide roles back and the deleted role no longer grants either policy to anyone. A failed restore stops cleanup before the role is deleted, preserving the information needed to recover safely.
Confirm no other role references either policy before deleting them:
curl -ksS --connect-timeout 3 --max-time 10 "${API7_ADMIN_URL}/api/permission_policies/${PRODUCTION_POLICY_ID}/references" -H "X-API-KEY: ${API7_ADMIN_KEY}" | jq -r '"production: " + (.total | tostring)'
curl -ksS --connect-timeout 3 --max-time 10 "${API7_ADMIN_URL}/api/permission_policies/${TEST_POLICY_ID}/references" -H "X-API-KEY: ${API7_ADMIN_KEY}" | jq -r '"test: " + (.total | tostring)'Expected: production: 0 and test: 0. If either is nonzero, you attached that policy to another role after Step 3 — detach it there before continuing.
PRODUCTION_DELETE_STATUS=$(curl -ksS --connect-timeout 3 --max-time 15 -o /dev/null -w '%{http_code}' "${API7_ADMIN_URL}/api/permission_policies/${PRODUCTION_POLICY_ID}" -X DELETE -H "X-API-KEY: ${API7_ADMIN_KEY}")
test "${PRODUCTION_DELETE_STATUS}" = 200 || { echo "Could not delete production policy: ${PRODUCTION_DELETE_STATUS}"; exit 1; }
TEST_DELETE_STATUS=$(curl -ksS --connect-timeout 3 --max-time 15 -o /dev/null -w '%{http_code}' "${API7_ADMIN_URL}/api/permission_policies/${TEST_POLICY_ID}" -X DELETE -H "X-API-KEY: ${API7_ADMIN_KEY}")
test "${TEST_DELETE_STATUS}" = 200 || { echo "Could not delete test policy: ${TEST_DELETE_STATUS}"; exit 1; }
rm -f "${ORIGINAL_ROLE_IDS_FILE}"Expected: no output. The backup file is removed only after the role and both policies have been deleted successfully.
Troubleshoot
| Symptom | Cause | Fix |
|---|---|---|
| After Step 4, the target user's other roles are gone. | PUT /api/users/{user_id}/assigned_roles replaces the complete role list; a request that names only the new role unassigns every role that was not included. | If ORIGINAL_ROLE_IDS_FILE from Step 4 exists, restore that saved array with the first Roll back / Clean up command. A new GET cannot recover roles after they have been overwritten. If the backup is missing, recover the former assignments from an administrator or audit record before writing another role list. |
DELETE on the role or a policy returns 400. | The role is still assigned to a user, or the policy is still referenced by a role. | Detach or unassign first: restore the target user's roles (Roll back / Clean up's first command) before deleting the role, and confirm GET .../permission_policies/{id}/references reports 0 before deleting a policy. |
| The Before-you-begin collision check prints one or more rows. | An earlier run of this guide created an object with the same name and guide: create-custom-role label that was never cleaned up. | Inspect the ID printed in each row and confirm ownership. Reuse the object or clean it up in dependency order; do not create a duplicate. |
Related
- Role-Based Access Control — how roles, users, and policies fit together.
- Permission Policies and Boundaries — the full JSON syntax and evaluation model.
- Permission Policy Actions and Resources — the supported actions and resource patterns.
- Permission Policy Examples — ready-to-adapt example policies.
- Design a Custom Role System — plan a role model that holds for larger organizations.