Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd5c5f4e88 | |||
| ccbbceaa47 |
@@ -19,6 +19,9 @@
|
|||||||
!API.md
|
!API.md
|
||||||
!directory_spec.md
|
!directory_spec.md
|
||||||
!docs/**/*.md
|
!docs/**/*.md
|
||||||
|
# The screenshots the README (served at /docs/overview) links. `COPY docs /docs`
|
||||||
|
# in Dockerfile.openldap needs these present in the build context.
|
||||||
|
!docs/images/**
|
||||||
|
|
||||||
# Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
|
# Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
|
||||||
# nodejs/tests/
|
# nodejs/tests/
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
# v1.26.0
|
||||||
|
- feat: complete the group model (docs/GROUPS.md) — `god_admin` is now seeded into LDAP and nested into `app_super_admin`; every site auto-provisions `{site}_super_admin`, `{site}_hosts_*`/`{site}_apps_*` aggregates and `{site}_everyone`; per-resource `_admin`/`_access` groups (named `{site}_{slug}_{level}`, the kind carried in the resource slug) are nested into the site aggregates so the inheritance lattice exists in LDAP, not just in the resolver. Site/aggregate groups are self-healed idempotently on every Directory load, so a directory seeded by an older release picks them up without a rebuild.
|
||||||
|
- feat: the naming convention is now enforced server-side — `POST /api/directory-admin/groups` rejects a group CN that isn't a valid group for the target resource (its own `_admin`/`_access`/capability, a site aggregate, a site-level group, or `god_admin`), so the free-text field can no longer mint `*_accessmember`-style names
|
||||||
|
- feat: `god_admin` is managed from the Directory — the site resource modal surfaces `god_admin` + the site-level groups as associated groups, so its members (and the site's) are editable right there
|
||||||
|
- fix: Directory agent status dots no longer paint every host red when the `/api/agent/nodes` endpoint is unreachable (older app or transient outage) — they now show a neutral grey "agent service unreachable" instead of a false alarm
|
||||||
|
- fix: Profile + API Tokens cards are both full-width on the profile page (the API card was a narrower centered block)
|
||||||
|
- fix: in-app `/docs/<slug>` pages returned 500 — `Dockerfile.openldap` never copied the `docs/` tree into the image (only the root README/CHANGELOG/API/directory_spec), so every page but those few hit a missing-file error; the whole `docs/` dir now ships, and doc images are served at `/docs/images`
|
||||||
|
- test: group resolver tests now cover the prefixed site-slug convention (`site_local_...` is kept verbatim, not re-slugified to `site-local`)
|
||||||
|
|
||||||
# v1.25.0
|
# v1.25.0
|
||||||
- feat: hierarchical group & permission model (docs/GROUPS.md) — god_admin, {site}_super_admin, {site}_hosts_*/{site}_apps_* aggregates, and per-resource {site}_host_<slug>_admin/access/<capability>; inheritance resolver (admin implies access, capabilities explicit), meta everyone/{site}_everyone groups
|
- feat: hierarchical group & permission model (docs/GROUPS.md) — god_admin, {site}_super_admin, {site}_hosts_*/{site}_apps_* aggregates, and per-resource {site}_host_<slug>_admin/access/<capability>; inheritance resolver (admin implies access, capabilities explicit), meta everyone/{site}_everyone groups
|
||||||
- feat: remove the standalone Groups page — group management is tied to adopted Directory resources (help link to the model in the Directory toolbar)
|
- feat: remove the standalone Groups page — group management is tied to adopted Directory resources (help link to the model in the Directory toolbar)
|
||||||
|
|||||||
@@ -184,6 +184,11 @@ COPY README.md /README.md
|
|||||||
COPY CHANGELOG.md /CHANGELOG.md
|
COPY CHANGELOG.md /CHANGELOG.md
|
||||||
COPY API.md /API.md
|
COPY API.md /API.md
|
||||||
COPY directory_spec.md /directory_spec.md
|
COPY directory_spec.md /directory_spec.md
|
||||||
|
# The docs/*.md tree (plus the images the docs link) is read at runtime too, so
|
||||||
|
# the whole docs/ dir must land at /docs. Without this every in-app /docs/<slug>
|
||||||
|
# page other than the root-level README/CHANGELOG/API/directory_spec 500s on the
|
||||||
|
# fs.readFileSync in routes/docs.js (files missing from the image).
|
||||||
|
COPY docs /docs
|
||||||
|
|
||||||
# Baked commit hash from the gitinfo stage (see build_info.js).
|
# Baked commit hash from the gitinfo stage (see build_info.js).
|
||||||
COPY --from=gitinfo /commit.txt ./.build_commit
|
COPY --from=gitinfo /commit.txt ./.build_commit
|
||||||
|
|||||||
+16
-1
@@ -355,7 +355,11 @@ EOF
|
|||||||
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
|
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
|
||||||
# app_sso_service_account is a marker (not a permission gate) for
|
# app_sso_service_account is a marker (not a permission gate) for
|
||||||
# non-person accounts -- see the Users page.
|
# non-person accounts -- see the Users page.
|
||||||
for group in app_super_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
|
#
|
||||||
|
# god_admin is the global super group (docs/GROUPS.md §2), the top of the
|
||||||
|
# group-inheritance lattice. It is seeded here so it exists from first boot;
|
||||||
|
# the theta-suite bootstrap puts the first admin person into it.
|
||||||
|
for group in god_admin app_super_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
|
||||||
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
|
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
|
||||||
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
||||||
objectClass: groupOfNames
|
objectClass: groupOfNames
|
||||||
@@ -385,6 +389,17 @@ member: cn=app_super_admin,ou=groups,${LDAP_BASE_DN}
|
|||||||
EOF
|
EOF
|
||||||
done
|
done
|
||||||
info "Nested app_super_admin into the SSO admin groups"
|
info "Nested app_super_admin into the SSO admin groups"
|
||||||
|
|
||||||
|
# god_admin is the top of the lattice; nesting it into app_super_admin
|
||||||
|
# (which is itself nested into the app_sso_* groups above) makes it
|
||||||
|
# resolve to everything app_super_admin holds at the LDAP level too.
|
||||||
|
ldapmodify -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 >/dev/null 2>&1 << EOF || true
|
||||||
|
dn: cn=app_super_admin,ou=groups,${LDAP_BASE_DN}
|
||||||
|
changetype: modify
|
||||||
|
add: member
|
||||||
|
member: cn=god_admin,ou=groups,${LDAP_BASE_DN}
|
||||||
|
EOF
|
||||||
|
info "Nested god_admin into app_super_admin"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
info "LDAP directory initialized"
|
info "LDAP directory initialized"
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.25.0",
|
"version": "1.26.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.25.0",
|
"version": "1.26.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.25.0",
|
"version": "1.26.0",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const { cnFromDn } = require('../utils/user_groups');
|
|||||||
const { projectResources } = require('@simpleworkjs/directory-schema');
|
const { projectResources } = require('@simpleworkjs/directory-schema');
|
||||||
|
|
||||||
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
|
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
|
||||||
|
const groups = require('../utils/groups');
|
||||||
|
|
||||||
// Make `childCn` a member of `parentCn`, i.e. everyone in the child is
|
// Make `childCn` a member of `parentCn`, i.e. everyone in the child is
|
||||||
// transitively in the parent. Idempotent and non-fatal: "already a member" is
|
// transitively in the parent. Idempotent and non-fatal: "already a member" is
|
||||||
@@ -29,6 +30,148 @@ async function nestGroup(childCn, parentCn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Group-model provisioning (docs/GROUPS.md) ───────────────────────────────
|
||||||
|
// The directory is the single place groups are created, as a projection of the
|
||||||
|
// resource graph. These helpers materialize the group-inheritance lattice for
|
||||||
|
// a resource so it exists in LDAP as well as in the resolver (utils/groups.js).
|
||||||
|
// All of them are idempotent, so calling them again for a resource a newer
|
||||||
|
// release is backfilling is a no-op.
|
||||||
|
|
||||||
|
// Map a directory resource kind onto a group-model kind (GROUPS.md §2).
|
||||||
|
// host -> host; service -> app (services/consoles are the group model's "apps");
|
||||||
|
// site gets site-level groups (handled separately); oauth/container get no
|
||||||
|
// per-resource groups (oauth clients hang off their owning service).
|
||||||
|
function groupKind(resource) {
|
||||||
|
if (resource.kind === 'host') return 'host';
|
||||||
|
if (resource.kind === 'service') return 'app';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a groupOfNames if it doesn't already exist. Idempotent; `ownerDn`
|
||||||
|
// seeds the mandatory first member. Returns true when created.
|
||||||
|
async function ensureGroup(name, ownerDn, description) {
|
||||||
|
try {
|
||||||
|
await Group.add({ name, owner: ownerDn, description });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) {
|
||||||
|
console.error(`ensureGroup: failed to create ${name}:`, err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provision the site-level groups + the aggregates the per-resource groups nest
|
||||||
|
// into. Idempotent -- called on every directory list so a site seeded by an
|
||||||
|
// older release gets its groups without a rebuild:
|
||||||
|
//
|
||||||
|
// god_admin -> {site}_super_admin
|
||||||
|
// {site}_super_admin -> {site}_hosts_admin, {site}_apps_admin
|
||||||
|
// {site}_hosts_admin -> {site}_hosts_access ; {site}_apps_admin -> {site}_apps_access
|
||||||
|
//
|
||||||
|
// `{site}_everyone` is created for completeness; it has implicit membership and
|
||||||
|
// is granted to a resource as a grantee, never enumerated.
|
||||||
|
async function ensureSiteGroups(siteSlug, ownerDn, siteName, siteResourceId) {
|
||||||
|
if (!siteSlug) return;
|
||||||
|
|
||||||
|
// Link a site group to the site resource (so it shows + is member-manageable
|
||||||
|
// on the site's modal). Idempotent. Admin groups link as owner; access/meta
|
||||||
|
// groups as member.
|
||||||
|
const link = async (cn, isAdmin) => {
|
||||||
|
if (!siteResourceId) return;
|
||||||
|
await ResourceGroup.create({ resourceId: siteResourceId, groupCn: cn, accessLevel: isAdmin ? 'owner' : 'member' }).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sAdmin = groups.siteSuperAdminCns(siteSlug);
|
||||||
|
await ensureGroup(sAdmin, ownerDn, `Site admin for ${siteName || siteSlug}`);
|
||||||
|
await link(sAdmin, true);
|
||||||
|
for (const kind of ['host', 'app']) {
|
||||||
|
const aggAdmin = groups.aggregateGroupCns(siteSlug, kind, 'admin');
|
||||||
|
const aggAccess = groups.aggregateGroupCns(siteSlug, kind, 'access');
|
||||||
|
await ensureGroup(aggAdmin, ownerDn, `Admin on all ${kind}s at ${siteSlug}`);
|
||||||
|
await ensureGroup(aggAccess, ownerDn, `Access to all ${kind}s at ${siteSlug}`);
|
||||||
|
await link(aggAdmin, true);
|
||||||
|
await link(aggAccess, false);
|
||||||
|
}
|
||||||
|
await ensureGroup(groups.siteEveryoneCns(siteSlug), ownerDn, `All users at ${siteSlug}`);
|
||||||
|
await link(groups.siteEveryoneCns(siteSlug), false);
|
||||||
|
// god_admin is the global group; surface it on the site modal so its members
|
||||||
|
// can be managed from the Directory (it has no home on a single resource).
|
||||||
|
await link(groups.GOD_ADMIN, true);
|
||||||
|
|
||||||
|
// Wire the lattice as nesting so LDAP-level consumers (SSSD, sudo, anything
|
||||||
|
// binding directly) resolve it transitively, not just utils/permission.js.
|
||||||
|
// nestGroup(child, parent) makes child a member of parent -- membership flows
|
||||||
|
// child -> parent ("up"), so a group's members inherit what its parents hold.
|
||||||
|
await nestGroup(groups.GOD_ADMIN, sAdmin); // god admins are site admins everywhere
|
||||||
|
for (const kind of ['host', 'app']) {
|
||||||
|
const aggAdmin = groups.aggregateGroupCns(siteSlug, kind, 'admin');
|
||||||
|
const aggAccess = groups.aggregateGroupCns(siteSlug, kind, 'access');
|
||||||
|
await nestGroup(sAdmin, aggAdmin); // site admins administer all hosts/apps
|
||||||
|
await nestGroup(aggAdmin, aggAccess); // site admin implies site access
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provision the per-resource groups for a host/app and nest them into the site
|
||||||
|
// aggregates (so a site/aggregate admin reaches this resource by membership).
|
||||||
|
// The specific group name uses the resource's slug verbatim
|
||||||
|
// (`{site}_{slug}_{level}` -- the kind is carried in the slug, e.g. `host_theta-env`);
|
||||||
|
// `kind` (host/app) selects which aggregate the group nests into:
|
||||||
|
//
|
||||||
|
// {site}_{slug}_admin -> {site}_{slug}_access
|
||||||
|
// {site}_{slug}_admin -> {site}_{kind}s_admin (aggregate)
|
||||||
|
// {site}_{slug}_access -> {site}_{kind}s_access (aggregate)
|
||||||
|
// app_super_admin -> {site}_{slug}_admin (legacy cross-app)
|
||||||
|
async function provisionResourceGroups(resource, kind, siteSlug, ownerDn) {
|
||||||
|
const accessCn = groups.resourceGroupCns(siteSlug, resource.slug, 'access');
|
||||||
|
const adminCn = groups.resourceGroupCns(siteSlug, resource.slug, 'admin');
|
||||||
|
|
||||||
|
await ensureGroup(accessCn, ownerDn, `Access group for ${resource.name}`);
|
||||||
|
await ensureGroup(adminCn, ownerDn, `Admin group for ${resource.name}`);
|
||||||
|
|
||||||
|
// Link both groups to the resource so the Directory can show/revoke them.
|
||||||
|
await ResourceGroup.create({ resourceId: resource.id, groupCn: accessCn, accessLevel: 'member' }).catch(() => {});
|
||||||
|
await ResourceGroup.create({ resourceId: resource.id, groupCn: adminCn, accessLevel: 'owner' }).catch(() => {});
|
||||||
|
|
||||||
|
await nestGroup(adminCn, accessCn); // administering implies using
|
||||||
|
await nestGroup(adminCn, groups.aggregateGroupCns(siteSlug, kind, 'admin')); // aggregate admin reaches this resource
|
||||||
|
await nestGroup(accessCn, groups.aggregateGroupCns(siteSlug, kind, 'access')); // aggregate access reaches this resource
|
||||||
|
await nestGroup(SUPER_ADMIN_GROUP, adminCn); // legacy cross-app super admin
|
||||||
|
}
|
||||||
|
|
||||||
|
// The group CNs it is valid to associate with a given resource (docs/GROUPS.md
|
||||||
|
// §2/§3). This is what "force the correct naming convention" means: a group
|
||||||
|
// linked to a resource must be one that parses for consumers -- the resource's
|
||||||
|
// own specific groups, its site's aggregates, site-level groups, or the global
|
||||||
|
// god_admin. Returns a Set of the fixed valid CNs plus a RegExp for opaque
|
||||||
|
// capability groups following the same shapes.
|
||||||
|
function validGroupCnsForResource(resource, siteSlug) {
|
||||||
|
const valid = new Set();
|
||||||
|
if (resource.kind === 'site') {
|
||||||
|
valid.add(groups.siteSuperAdminCns(siteSlug));
|
||||||
|
valid.add(groups.siteEveryoneCns(siteSlug));
|
||||||
|
for (const k of ['host', 'app']) {
|
||||||
|
valid.add(groups.aggregateGroupCns(siteSlug, k, 'admin'));
|
||||||
|
valid.add(groups.aggregateGroupCns(siteSlug, k, 'access'));
|
||||||
|
}
|
||||||
|
return { valid, capRe: new RegExp(`^${siteSlug}_(hosts|apps)_[a-z0-9-]+$`) };
|
||||||
|
}
|
||||||
|
const kind = groupKind(resource); // 'host'|'app'|null
|
||||||
|
if (kind) {
|
||||||
|
const slug = resource.slug; // verbatim (kind is carried in the slug)
|
||||||
|
valid.add(groups.resourceGroupCns(siteSlug, slug, 'admin'));
|
||||||
|
valid.add(groups.resourceGroupCns(siteSlug, slug, 'access'));
|
||||||
|
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'admin'));
|
||||||
|
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'access'));
|
||||||
|
valid.add(groups.siteSuperAdminCns(siteSlug));
|
||||||
|
valid.add(groups.siteEveryoneCns(siteSlug));
|
||||||
|
return { valid, capRe: new RegExp(`^${siteSlug}_(${slug}_|${kind}s_)[a-z0-9-]+$`) };
|
||||||
|
}
|
||||||
|
// oauth/container etc. — only the global god_admin makes sense to pin here.
|
||||||
|
valid.add(groups.siteSuperAdminCns(siteSlug));
|
||||||
|
return { valid, capRe: null };
|
||||||
|
}
|
||||||
|
|
||||||
// Require the admin group
|
// Require the admin group
|
||||||
router.use(async (req, res, next) => {
|
router.use(async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -50,6 +193,36 @@ router.get('/resources', async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
// Even admins never receive secret metadata (e.g. client_secret_hash) over
|
// Even admins never receive secret metadata (e.g. client_secret_hash) over
|
||||||
// the wire; projectResources strips it unconditionally.
|
// the wire; projectResources strips it unconditionally.
|
||||||
|
|
||||||
|
// Self-heal the group model (docs/GROUPS.md): ensure every site has its
|
||||||
|
// site-level groups (S_super_admin, S_hosts_*, S_apps_*, S_everyone) + the
|
||||||
|
// aggregates, and every host/app resource has its per-resource groups nested
|
||||||
|
// into them. Idempotent, so this is a cheap no-op once present -- it's what
|
||||||
|
// backfills a directory seeded by an older release without a rebuild.
|
||||||
|
// Never fails the list.
|
||||||
|
const sites = resources.filter(r => r.kind === 'site');
|
||||||
|
await Promise.all(sites.map(site =>
|
||||||
|
ensureSiteGroups(site.slug, req.user.dn, site.name, site.id)
|
||||||
|
.catch(err => console.error(`ensureSiteGroups(${site.slug}) failed:`, err.message))
|
||||||
|
));
|
||||||
|
const siteByResource = new Map();
|
||||||
|
for (const site of sites) siteByResource.set(site.id, site.slug);
|
||||||
|
const siteOf = async (r) => {
|
||||||
|
const direct = siteByResource.get(r.id);
|
||||||
|
if (direct) return direct;
|
||||||
|
// findAncestorSiteSlug returns the site's full slug (`site_local`) -- the
|
||||||
|
// group-model builders take it verbatim, so do NOT strip the `site_` prefix.
|
||||||
|
return await Resource.findAncestorSiteSlug(r.id).catch(() => null);
|
||||||
|
};
|
||||||
|
await Promise.all(resources.map(async (r) => {
|
||||||
|
const gKind = groupKind(r);
|
||||||
|
if (!gKind) return;
|
||||||
|
const siteSlug = await siteOf(r);
|
||||||
|
if (!siteSlug) return;
|
||||||
|
await provisionResourceGroups(r, gKind, siteSlug, req.user.dn)
|
||||||
|
.catch(err => console.error(`provisionResourceGroups(${r.slug}) failed:`, err.message));
|
||||||
|
}));
|
||||||
|
|
||||||
res.json({ results: projectResources(resources, { fullMetadata: true }) });
|
res.json({ results: projectResources(resources, { fullMetadata: true }) });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
@@ -95,44 +268,23 @@ router.post('/resources', async (req, res, next) => {
|
|||||||
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
|
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.kind === 'host' || r.kind === 'service') {
|
// ── Group provisioning (docs/GROUPS.md) ───────────────────────────────
|
||||||
const siteSlug = await Resource.findAncestorSiteSlug(r.id);
|
// Materialize the group-model for the new resource. Site resources get the
|
||||||
const groupCn = suffix => (siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`);
|
// site-level groups; host/app resources get their per-resource groups nested
|
||||||
|
// into the site aggregates. Idempotent -- safe for a resource created by an
|
||||||
const createGroup = async (suffix, accessLevel) => {
|
// older release. A provisioning failure must not fail resource creation: the
|
||||||
const cn = groupCn(suffix);
|
// resource already exists and the groups are repairable (re-run ensures them).
|
||||||
try {
|
//
|
||||||
await Group.add({
|
// `siteSlug` is the site resource's slug verbatim (`site_local`) -- the
|
||||||
name: cn,
|
// group-model builders treat it as opaque (docs/GROUPS.md §3) and re-apply
|
||||||
owner: req.user.dn,
|
// the kind prefix themselves.
|
||||||
description: `${suffix === 'admin' ? 'Admin' : 'Access'} group for ${r.name}`
|
const gKind = groupKind(r);
|
||||||
});
|
const ancestorSite = await Resource.findAncestorSiteSlug(r.id);
|
||||||
} catch (err) {
|
if (r.kind === 'site') {
|
||||||
if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) {
|
await ensureSiteGroups(r.slug, req.user.dn, r.name, r.id);
|
||||||
console.error(`Failed to create LDAP group ${cn}:`, err);
|
} else if (gKind && ancestorSite) {
|
||||||
}
|
await ensureSiteGroups(ancestorSite, req.user.dn, r.name); // backfill site tier if missing
|
||||||
}
|
await provisionResourceGroups(r, gKind, ancestorSite, req.user.dn);
|
||||||
try {
|
|
||||||
await ResourceGroup.create({ resourceId: r.id, groupCn: cn, accessLevel });
|
|
||||||
} catch(err) { /* ignore duplicate links */ }
|
|
||||||
};
|
|
||||||
await createGroup('access', 'member');
|
|
||||||
await createGroup('admin', 'owner');
|
|
||||||
|
|
||||||
// Wire up the two standing relationships every resource has, as nesting
|
|
||||||
// rather than as membership that has to be maintained per resource:
|
|
||||||
//
|
|
||||||
// app_super_admin -> <slug>_admin cross-app super admins administer
|
|
||||||
// every resource, automatically
|
|
||||||
// <slug>_admin -> <slug>_access administering something implies
|
|
||||||
// being able to use it
|
|
||||||
//
|
|
||||||
// Before nesting, both of these could only be expressed by adding every
|
|
||||||
// super admin to every new group by hand -- which nobody does, so the
|
|
||||||
// groups drifted. A failure here must not fail resource creation: the
|
|
||||||
// resource and its groups already exist and the nesting is repairable.
|
|
||||||
await nestGroup(groupCn('admin'), groupCn('access'));
|
|
||||||
await nestGroup(SUPER_ADMIN_GROUP, groupCn('admin'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ results: r });
|
res.json({ results: r });
|
||||||
@@ -265,6 +417,28 @@ router.get('/groups', async (req, res, next) => {
|
|||||||
|
|
||||||
router.post('/groups', async (req, res, next) => {
|
router.post('/groups', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const { resourceId, groupCn } = req.body;
|
||||||
|
if (!resourceId || !groupCn) return res.status(400).json({ error: 'resourceId and groupCn are required' });
|
||||||
|
|
||||||
|
// Enforce the group-model naming convention (docs/GROUPS.md §3). The CN must
|
||||||
|
// be a valid group for this resource; reject free-form names so the groups
|
||||||
|
// consumers read are always parseable. god_admin is always allowed (it is
|
||||||
|
// the global group and is managed from a site's modal).
|
||||||
|
const resource = await Resource.get(resourceId);
|
||||||
|
// Full site slug verbatim (`site_local`) -- the builders take it as-is. A
|
||||||
|
// site resource's own slug is its site; a host/app uses its ancestor site.
|
||||||
|
const siteSlug = resource && resource.kind === 'site'
|
||||||
|
? resource.slug
|
||||||
|
: await Resource.findAncestorSiteSlug(resourceId);
|
||||||
|
if (resource && siteSlug && groupCn !== groups.GOD_ADMIN) {
|
||||||
|
const { valid, capRe } = validGroupCnsForResource(resource, siteSlug);
|
||||||
|
if (!valid.has(groupCn) && !(capRe && capRe.test(groupCn))) {
|
||||||
|
const err = new Error(`"${groupCn}" is not a valid group for this ${resource.kind}. Use the resource's own groups, a site aggregate, a site-level group, or god_admin (e.g. ${[...valid].join(', ')}).`);
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const g = await ResourceGroup.create(req.body);
|
const g = await ResourceGroup.create(req.body);
|
||||||
res.json({ results: g });
|
res.json({ results: g });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const docList = Object.entries(DOCS).map(([slug, d]) => ({slug, title: d.title})
|
|||||||
// only resolves correctly on GitHub. Serve that same folder here and rewrite
|
// only resolves correctly on GitHub. Serve that same folder here and rewrite
|
||||||
// the rendered markup to point at it absolutely, so the images work when
|
// the rendered markup to point at it absolutely, so the images work when
|
||||||
// read from /docs/overview too.
|
// read from /docs/overview too.
|
||||||
router.use('/images', require('express').static(path.join(__dirname, '../../docs/images')));
|
router.use('/docs/images', require('express').static(path.join(__dirname, '../../docs/images')));
|
||||||
function fixImagePaths(html) {
|
function fixImagePaths(html) {
|
||||||
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
|
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-21
@@ -12,9 +12,12 @@ const {
|
|||||||
GOD_ADMIN,
|
GOD_ADMIN,
|
||||||
} = require('../utils/groups');
|
} = require('../utils/groups');
|
||||||
|
|
||||||
const HOST = { site: 'Main Office', kind: 'host', slug: 'Web 01' };
|
// Resource fixtures mirror the directory's real slugs: hosts carry a `host_`
|
||||||
|
// prefix, services/apps are stored bare. The group-model builders use these
|
||||||
|
// verbatim (no re-slugifying, no kind insertion) -- see groups.js.
|
||||||
|
const HOST = { site: 'main-office', kind: 'host', slug: 'host_web-01' };
|
||||||
const APP = { site: 'main-office', kind: 'app', slug: 'emby' };
|
const APP = { site: 'main-office', kind: 'app', slug: 'emby' };
|
||||||
const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'db' };
|
const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'host_db' };
|
||||||
|
|
||||||
describe('slugify', () => {
|
describe('slugify', () => {
|
||||||
test('lowercases, spaces and underscores become hyphens, no leading/trailing dash', () => {
|
test('lowercases, spaces and underscores become hyphens, no leading/trailing dash', () => {
|
||||||
@@ -24,27 +27,31 @@ describe('slugify', () => {
|
|||||||
expect(slugify(' Mixed CASE--name ')).toBe('mixed-case-name');
|
expect(slugify(' Mixed CASE--name ')).toBe('mixed-case-name');
|
||||||
expect(slugify('')).toBe('');
|
expect(slugify('')).toBe('');
|
||||||
});
|
});
|
||||||
test('never contains an underscore (the structural delimiter)', () => {
|
|
||||||
expect(slugify('a_b_c')).not.toContain('_');
|
|
||||||
expect(resourceGroupCns('Main Office', 'host', 'Web 01', 'access')).not.toContain('__');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('group cn builders', () => {
|
describe('group cn builders', () => {
|
||||||
test('per-resource uses singular kind', () => {
|
test('per-resource uses the resource slug verbatim (kind is carried in the slug)', () => {
|
||||||
expect(resourceGroupCns('main-office', 'host', 'web-01', 'admin')).toBe('main-office_host_web-01_admin');
|
expect(resourceGroupCns('main-office', 'host_web-01', 'admin')).toBe('main-office_host_web-01_admin');
|
||||||
expect(resourceGroupCns('main-office', 'app', 'emby', 'access')).toBe('main-office_app_emby_access');
|
expect(resourceGroupCns('main-office', 'emby', 'access')).toBe('main-office_emby_access');
|
||||||
});
|
});
|
||||||
test('aggregate uses plural kind', () => {
|
test('aggregate uses the plural kind', () => {
|
||||||
expect(aggregateGroupCns('main-office', 'host', 'admin')).toBe('main-office_hosts_admin');
|
expect(aggregateGroupCns('main-office', 'host', 'admin')).toBe('main-office_hosts_admin');
|
||||||
expect(aggregateGroupCns('main-office', 'app', 'access')).toBe('main-office_apps_access');
|
expect(aggregateGroupCns('main-office', 'app', 'access')).toBe('main-office_apps_access');
|
||||||
});
|
});
|
||||||
test('site super admin + everyone', () => {
|
test('site super admin + everyone', () => {
|
||||||
expect(siteSuperAdminCns('Main Office')).toBe('main-office_super_admin');
|
expect(siteSuperAdminCns('main-office')).toBe('main-office_super_admin');
|
||||||
expect(siteEveryoneCns('main-office')).toBe('main-office_everyone');
|
expect(siteEveryoneCns('main-office')).toBe('main-office_everyone');
|
||||||
});
|
});
|
||||||
test('invalid kind throws', () => {
|
test('a directory site slug with a kind prefix is kept verbatim, not re-slugified', () => {
|
||||||
expect(() => resourceGroupCns('s', 'service', 'x', 'admin')).toThrow();
|
// Resource slugs are `site_local` / `host_theta-env` -- re-slugifying the
|
||||||
|
// site (`site_local` -> `site-local`) would corrupt the delimiter.
|
||||||
|
expect(siteSuperAdminCns('site_local')).toBe('site_local_super_admin');
|
||||||
|
expect(siteEveryoneCns('site_local')).toBe('site_local_everyone');
|
||||||
|
expect(aggregateGroupCns('site_local', 'host', 'admin')).toBe('site_local_hosts_admin');
|
||||||
|
expect(resourceGroupCns('site_local', 'host_theta-env', 'access')).toBe('site_local_host_theta-env_access');
|
||||||
|
});
|
||||||
|
test('invalid kind throws (aggregates only — per-resource has no kind arg)', () => {
|
||||||
|
expect(() => aggregateGroupCns('s', 'service', 'admin')).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,32 +89,32 @@ describe('hasPermission — inheritance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('specific host group grants only that host', () => {
|
test('specific host group grants only that host', () => {
|
||||||
const cn = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
|
const cn = resourceGroupCns('main-office', 'host_web-01', 'admin');
|
||||||
expect(hasPermission([cn], HOST, 'admin')).toBe(true);
|
expect(hasPermission([cn], HOST, 'admin')).toBe(true);
|
||||||
expect(hasPermission([cn], OTHER_SITE_HOST, 'admin')).toBe(false);
|
expect(hasPermission([cn], OTHER_SITE_HOST, 'admin')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('admin implies access; access does not imply admin', () => {
|
test('admin implies access; access does not imply admin', () => {
|
||||||
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'access')).toBe(true);
|
expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'admin')], HOST, 'access')).toBe(true);
|
||||||
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'access')], HOST, 'admin')).toBe(false);
|
expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'access')], HOST, 'admin')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('capabilities are exact — admin does not grant a capability', () => {
|
test('capabilities are exact — admin does not grant a capability', () => {
|
||||||
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'reboot')], HOST, 'reboot')).toBe(true);
|
expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'reboot')], HOST, 'reboot')).toBe(true);
|
||||||
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'reboot')).toBe(false);
|
expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'admin')], HOST, 'reboot')).toBe(false);
|
||||||
// aggregate capability
|
// aggregate capability
|
||||||
expect(hasPermission(['main-office_hosts_reboot'], HOST, 'reboot')).toBe(true);
|
expect(hasPermission(['main-office_hosts_reboot'], HOST, 'reboot')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('hosts and apps are orthogonal namespaces', () => {
|
test('hosts and apps are orthogonal namespaces', () => {
|
||||||
const hostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
|
const hostAdmin = resourceGroupCns('main-office', 'host_web-01', 'admin');
|
||||||
expect(hasPermission([hostAdmin], APP, 'access')).toBe(false);
|
expect(hasPermission([hostAdmin], APP, 'access')).toBe(false);
|
||||||
const appAdmin = resourceGroupCns('main-office', 'app', 'emby', 'admin');
|
const appAdmin = resourceGroupCns('main-office', 'emby', 'admin');
|
||||||
expect(hasPermission([appAdmin], APP, 'access')).toBe(true);
|
expect(hasPermission([appAdmin], APP, 'access')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cross-site isolation', () => {
|
test('cross-site isolation', () => {
|
||||||
const mainHostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
|
const mainHostAdmin = resourceGroupCns('main-office', 'host_web-01', 'admin');
|
||||||
expect(hasPermission([mainHostAdmin], OTHER_SITE_HOST, 'access')).toBe(false);
|
expect(hasPermission([mainHostAdmin], OTHER_SITE_HOST, 'access')).toBe(false);
|
||||||
expect(hasPermission(['branch-office_hosts_admin'], OTHER_SITE_HOST, 'admin')).toBe(true);
|
expect(hasPermission(['branch-office_hosts_admin'], OTHER_SITE_HOST, 'admin')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
+20
-11
@@ -41,26 +41,33 @@ function assertKind(kind) {
|
|||||||
if (!KINDS.includes(kind)) throw new Error(`invalid resource kind: ${kind} (must be host or app)`);
|
if (!KINDS.includes(kind)) throw new Error(`invalid resource kind: ${kind} (must be host or app)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// {site}_host_<slug>_<level> / {site}_app_<slug>_<level>
|
// {site}_{slug}_{level} — the per-resource group for one resource.
|
||||||
function resourceGroupCns(site, kind, slug, level) {
|
//
|
||||||
assertKind(kind);
|
// Both `site` and `slug` are the resource slugs verbatim (e.g. `site_local`,
|
||||||
return `${slugify(site)}_${kind}_${slugify(slug)}_${level}`;
|
// `host_theta-env`), NOT slugified or kind-inserted: directory resource slugs
|
||||||
|
// carry their kind as a prefix (`host_theta-env`), so `site_local` + `host_theta-env`
|
||||||
|
// yields `site_local_host_theta-env_access`. Services are stored without a
|
||||||
|
// prefix (`sso-manager`), yielding `site_local_sso-manager_access`. This is the
|
||||||
|
// convention the auto-provisioner, the resolver, and the access-request tests
|
||||||
|
// all share -- re-slugifying or inserting a kind would double the delimiter.
|
||||||
|
function resourceGroupCns(site, slug, level) {
|
||||||
|
return `${site}_${slug}_${level}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// {site}_hosts_<level> / {site}_apps_<level> (plural kind — the aggregate).
|
// {site}_hosts_<level> / {site}_apps_<level> (plural kind — the aggregate).
|
||||||
function aggregateGroupCns(site, kind, level) {
|
function aggregateGroupCns(site, kind, level) {
|
||||||
assertKind(kind);
|
assertKind(kind);
|
||||||
return `${slugify(site)}_${kind}s_${level}`;
|
return `${site}_${kind}s_${level}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// {site}_super_admin
|
// {site}_super_admin
|
||||||
function siteSuperAdminCns(site) {
|
function siteSuperAdminCns(site) {
|
||||||
return `${slugify(site)}_super_admin`;
|
return `${site}_super_admin`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// {site}_everyone
|
// {site}_everyone
|
||||||
function siteEveryoneCns(site) {
|
function siteEveryoneCns(site) {
|
||||||
return `${slugify(site)}_everyone`;
|
return `${site}_everyone`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// True if `level` is a known admin/access level (not an opaque capability).
|
// True if `level` is a known admin/access level (not an opaque capability).
|
||||||
@@ -87,9 +94,11 @@ function levelGrants(level, wanted) {
|
|||||||
// granted groups (see permission.onResource). This keeps the function pure over
|
// granted groups (see permission.onResource). This keeps the function pure over
|
||||||
// the user's membership only.
|
// the user's membership only.
|
||||||
function hasPermission(memberOf, resource, level) {
|
function hasPermission(memberOf, resource, level) {
|
||||||
const site = slugify(resource && resource.site);
|
// `site` and `slug` are used verbatim (resource slugs may carry a kind prefix,
|
||||||
|
// e.g. `site_local` / `host_theta-env`) -- see resourceGroupCns.
|
||||||
|
const site = resource && resource.site;
|
||||||
const kind = resource && resource.kind;
|
const kind = resource && resource.kind;
|
||||||
const slug = slugify(resource && resource.slug);
|
const slug = resource && resource.slug;
|
||||||
const set = new Set(memberOf || []);
|
const set = new Set(memberOf || []);
|
||||||
|
|
||||||
if (set.has(GOD_ADMIN)) return true;
|
if (set.has(GOD_ADMIN)) return true;
|
||||||
@@ -98,13 +107,13 @@ function hasPermission(memberOf, resource, level) {
|
|||||||
if (isKnownLevel(level)) {
|
if (isKnownLevel(level)) {
|
||||||
// admin / access
|
// admin / access
|
||||||
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
||||||
if (set.has(resourceGroupCns(site, kind, slug, level))) return true;
|
if (set.has(resourceGroupCns(site, slug, level))) return true;
|
||||||
if (level === 'access' && hasPermission(memberOf, resource, 'admin')) return true;
|
if (level === 'access' && hasPermission(memberOf, resource, 'admin')) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Opaque capability — exact aggregate or specific grant only.
|
// Opaque capability — exact aggregate or specific grant only.
|
||||||
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
||||||
if (set.has(resourceGroupCns(site, kind, slug, level))) return true;
|
if (set.has(resourceGroupCns(site, slug, level))) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -530,6 +530,11 @@
|
|||||||
// tab read from these. Agent data comes from /api/agent/nodes (admin-gated).
|
// tab read from these. Agent data comes from /api/agent/nodes (admin-gated).
|
||||||
var agentsByHost = {};
|
var agentsByHost = {};
|
||||||
var agentsByToken = {};
|
var agentsByToken = {};
|
||||||
|
// True when the agent/nodes endpoint itself was unreachable (network, or an
|
||||||
|
// older app without the agent route). When set we cannot tell "this host has
|
||||||
|
// no agent" apart from "the agent service is down", so we must NOT paint every
|
||||||
|
// host red as if it lacked an agent.
|
||||||
|
var agentsUnavailable = false;
|
||||||
|
|
||||||
async function loadResources() {
|
async function loadResources() {
|
||||||
try {
|
try {
|
||||||
@@ -540,8 +545,12 @@
|
|||||||
// Access counts are a nicety, not load-bearing: if the LDAP join fails
|
// Access counts are a nicety, not load-bearing: if the LDAP join fails
|
||||||
// the table still renders, just without the Access column populated.
|
// the table still renders, just without the Access column populated.
|
||||||
app.api.get('directory-admin/access-summary').catch(function(){ return {results: {}}; }),
|
app.api.get('directory-admin/access-summary').catch(function(){ return {results: {}}; }),
|
||||||
// Agents are a nicety too: never block the directory on them.
|
// Agents are a nicety too: never block the directory on them. Track
|
||||||
app.api.get('agent/nodes').catch(function(){ return {agents: []}; })
|
// whether the endpoint itself is reachable so host rows can tell "no
|
||||||
|
// agent on this host" from "agent service is down" (see attachAgentStatus).
|
||||||
|
app.api.get('agent/nodes')
|
||||||
|
.then(function(res){ agentsUnavailable = false; return res; })
|
||||||
|
.catch(function(){ agentsUnavailable = true; return {agents: []}; })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
accessSummary = (resAccess && resAccess.results) || {};
|
accessSummary = (resAccess && resAccess.results) || {};
|
||||||
@@ -610,7 +619,12 @@
|
|||||||
const a = agentsByHost[name] || (slug && agentsByHost[slug]);
|
const a = agentsByHost[name] || (slug && agentsByHost[slug]);
|
||||||
n.agent = a || null;
|
n.agent = a || null;
|
||||||
if (resourcesById[n.id]) resourcesById[n.id].agent = a || null;
|
if (resourcesById[n.id]) resourcesById[n.id].agent = a || null;
|
||||||
if (!a) { n.agentColor = '#dc3545'; n.agentStatusTitle = 'No theta-agent connected'; return; }
|
if (!a) {
|
||||||
|
// Endpoint unreachable: we genuinely don't know -- neutral grey, not a
|
||||||
|
// false red alarm across every host.
|
||||||
|
if (agentsUnavailable) { n.agentColor = '#adb5bd'; n.agentStatusTitle = 'Agent service unreachable'; return; }
|
||||||
|
n.agentColor = '#dc3545'; n.agentStatusTitle = 'No theta-agent connected'; return;
|
||||||
|
}
|
||||||
if (!a.isOnline) { n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent offline (' + (a.hostname || 'unknown') + ')'; return; }
|
if (!a.isOnline) { n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent offline (' + (a.hostname || 'unknown') + ')'; return; }
|
||||||
const t = a.telemetry || {};
|
const t = a.telemetry || {};
|
||||||
const high = (t.cpu_usage_percent > 80) || (t.ram_usage_percent > 80) || (t.disk_usage_percent > 90);
|
const high = (t.cpu_usage_percent > 80) || (t.ram_usage_percent > 80) || (t.disk_usage_percent > 90);
|
||||||
@@ -655,8 +669,12 @@
|
|||||||
try {
|
try {
|
||||||
const res = await app.api.get('agent/nodes');
|
const res = await app.api.get('agent/nodes');
|
||||||
indexAgents((res && res.agents) || []);
|
indexAgents((res && res.agents) || []);
|
||||||
|
agentsUnavailable = false;
|
||||||
renderTable();
|
renderTable();
|
||||||
} catch (e) { /* non-fatal */ }
|
} catch (e) {
|
||||||
|
agentsUnavailable = true;
|
||||||
|
renderTable(); // re-render so dots flip to neutral, not stale green
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Who can reach this?" at a glance. A resource with no linked group is not a
|
// "Who can reach this?" at a glance. A resource with no linked group is not a
|
||||||
|
|||||||
@@ -657,8 +657,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="own-api-tokens-section" style="display:none">
|
<div id="own-api-tokens-section" style="display:none">
|
||||||
<div class="row mt-3 justify-content-center">
|
<div class="row mt-3">
|
||||||
<div class="col-md-8">
|
<div class="col-12">
|
||||||
<div class="card shadow-lg">
|
<div class="card shadow-lg">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
|
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user