diff --git a/nodejs/config/inventory.sqlite b/nodejs/config/inventory.sqlite index a027ce2..9847b23 100644 Binary files a/nodejs/config/inventory.sqlite and b/nodejs/config/inventory.sqlite differ diff --git a/nodejs/models/resource.js b/nodejs/models/resource.js index 217988a..399a4ad 100644 --- a/nodejs/models/resource.js +++ b/nodejs/models/resource.js @@ -152,10 +152,36 @@ class Resource extends Model { owner: { type: 'string' }, description: { type: 'text' }, metadata: { type: 'json', default: {} }, + // Not isRequired: @simpleworkjs/orm has no auto-timestamp hook, so these + // are set explicitly by the route handler on every create/update (see + // routes/api_directory_admin.js). Existing rows predating this change + // simply read back undefined -- callers must render a fallback. + created_by: { type: 'string' }, + created_on: { type: 'integer' }, + updated_by: { type: 'string' }, + updated_on: { type: 'integer' }, edgesAsParent: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'parentId' }, edgesAsChild: { type: 'hasMany', model: 'ResourceEdge', remoteKey: 'childId' }, groups: { type: 'hasMany', model: 'ResourceGroup', remoteKey: 'resourceId' } }; + + // Walk parent ResourceEdges from resourceId up to the nearest ancestor + // whose kind === 'site', returning its slug (or null if none exists -- a + // top-level resource with no site parent keeps its unprefixed group name). + static async findAncestorSiteSlug(resourceId, visited = new Set()) { + if (visited.has(resourceId)) return null; + visited.add(resourceId); + + const parentEdges = await ResourceEdge.list({ where: { childId: resourceId } }); + for (const edge of parentEdges) { + const parent = await this.get(edge.parentId); + if (!parent) continue; + if (parent.kind === 'site') return parent.slug; + const found = await this.findAncestorSiteSlug(parent.id, visited); + if (found) return found; + } + return null; + } } class ResourceEdge extends Model { diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 49bde5b..fe757d0 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "t42-sso-manager", - "version": "1.5.1", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t42-sso-manager", - "version": "1.5.1", + "version": "1.7.0", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", @@ -14,7 +14,7 @@ "@simpleworkjs/app-stack": "^1.0.0", "@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/directory-schema": "^1.0.0", - "@simpleworkjs/frontend": "^0.2.5", + "@simpleworkjs/frontend": "^0.2.6", "@simpleworkjs/ldap": "^1.0.0", "@simpleworkjs/orm": "^0.2.8", "bcrypt": "^6.0.0", @@ -1280,9 +1280,9 @@ } }, "node_modules/@simpleworkjs/frontend": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.5.tgz", - "integrity": "sha512-PxR7UVPv3gRpdF0WsuAZplF1vYvKsEJQevVPhz9d72U+69vP/OH3tlaAXjtO/apMHfhT1viOPw2gMVOrPSxYZw==", + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@simpleworkjs/frontend/-/frontend-0.2.6.tgz", + "integrity": "sha512-2uqvEjxyZ2LE+sfhP6rJcEMmqdViazJ3ZkitWJXInPMWF6DiEZuP5MYqBqJvfDko63CCHEt1/ChFQd7Ry85Pzg==", "license": "MIT", "engines": { "node": ">=18.0.0" diff --git a/nodejs/package.json b/nodejs/package.json index f713ec6..7ce6f84 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -26,7 +26,7 @@ "@simpleworkjs/app-stack": "^1.0.0", "@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/directory-schema": "^1.0.0", - "@simpleworkjs/frontend": "^0.2.5", + "@simpleworkjs/frontend": "^0.2.6", "@simpleworkjs/ldap": "^1.0.0", "@simpleworkjs/orm": "^0.2.8", "bcrypt": "^6.0.0", diff --git a/nodejs/routes/api_directory_admin.js b/nodejs/routes/api_directory_admin.js index 46a0320..c70bd0e 100644 --- a/nodejs/routes/api_directory_admin.js +++ b/nodejs/routes/api_directory_admin.js @@ -43,25 +43,33 @@ router.post('/resources', async (req, res, next) => { } req.body.owner = req.body.owner || req.user.uid; - + + const now = Date.now(); + req.body.created_by = req.body.created_by || req.user.uid; + req.body.created_on = now; + req.body.updated_by = req.user.uid; + req.body.updated_on = now; + let r; if (req.body.kind === 'oauth') { const { OAuthClient } = require('../models/oauth_client'); - // Pass created_by explicitly for the wrapper + // Pass created_by explicitly for the wrapper (overrides the generic + // assignment above -- this is OAuthClient-wrapper-specific behavior). req.body.created_by = req.body.owner; // In the UI we might pass slug, but OAuthClient wrapper expects name r = await OAuthClient.add(req.body); } else { r = await Resource.create(req.body); } - + if ((r.kind === 'host' || r.kind === 'service' || r.kind === 'oauth') && req.body.hostId) { await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' }); } - + if (r.kind === 'host' || r.kind === 'service') { + const siteSlug = await Resource.findAncestorSiteSlug(r.id); const createGroup = async (suffix, accessLevel) => { - const cn = `${r.slug}_${suffix}`; + const cn = siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`; try { await Group.add({ name: cn, @@ -103,7 +111,10 @@ router.put('/resources/:id', async (req, res, next) => { r = await Resource.get(req.params.id); } if (!r) return res.status(404).json({ error: 'Not found' }); - + + req.body.updated_by = req.user.uid; + req.body.updated_on = Date.now(); + if (req.body.kind === 'host' && !req.body.hostId) { return res.status(400).json({ error: 'Hosts must have a parent Site or Host' }); } diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index 9da6db4..05bac0f 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -60,6 +60,14 @@ router.get('/directory', function(req, res) { res.render('directory', {...values}); }); +// Linkable deep-link to a single resource's modal, e.g. from the resource +// modal's app.modal `url` option. Mirrors /users/:uid below: no server-side +// use of :slug at all -- the client reads location.pathname itself and opens +// the matching resource's modal once the page's own data has loaded. +router.get('/directory/:slug', function(req, res) { + res.render('directory', {...values}); +}); + // Route removed since it's now in directory router.get('/onboarding', async function(req, res, next) { diff --git a/nodejs/tests/resource_site_slug.test.js b/nodejs/tests/resource_site_slug.test.js new file mode 100644 index 0000000..fafe407 --- /dev/null +++ b/nodejs/tests/resource_site_slug.test.js @@ -0,0 +1,63 @@ +'use strict'; + +// findAncestorSiteSlug has no LDAP dependency (unlike most of this test +// suite, which needs a live LDAP server) -- it's pure Resource/ResourceEdge +// graph traversal against the ORM, so it's tested directly here rather than +// through the (LDAP-gated) directory-admin HTTP routes. + +const { initORM } = require('../models'); +const { Resource, ResourceEdge } = require('../models/resource'); + +const marker = 'test_site_slug_' + Date.now(); +const created = []; + +async function makeResource(kind, name) { + const r = await Resource.create({ kind, name, slug: `${marker}_${name}` }); + created.push(r); + return r; +} + +beforeAll(async () => { + await initORM(); +}); + +afterAll(async () => { + for (const r of created) { + try { await r.delete(); } catch (_) {} + } +}); + +describe('Resource.findAncestorSiteSlug', () => { + test('returns the direct parent site\'s slug', async () => { + const site = await makeResource('site', 'site-direct'); + const host = await makeResource('host', 'host-direct'); + await ResourceEdge.create({ parentId: site.id, childId: host.id, relation: 'hosts' }); + + await expect(Resource.findAncestorSiteSlug(host.id)).resolves.toBe(site.slug); + }); + + test('walks up through an intermediate host to find the owning site', async () => { + const site = await makeResource('site', 'site-nested'); + const host = await makeResource('host', 'host-nested'); + const service = await makeResource('service', 'service-nested'); + await ResourceEdge.create({ parentId: site.id, childId: host.id, relation: 'hosts' }); + await ResourceEdge.create({ parentId: host.id, childId: service.id, relation: 'hosts' }); + + await expect(Resource.findAncestorSiteSlug(service.id)).resolves.toBe(site.slug); + }); + + test('returns null for a top-level resource with no site ancestor', async () => { + const host = await makeResource('host', 'host-orphan'); + + await expect(Resource.findAncestorSiteSlug(host.id)).resolves.toBeNull(); + }); + + test('does not loop forever on a cyclic parent chain', async () => { + const a = await makeResource('host', 'host-cycle-a'); + const b = await makeResource('host', 'host-cycle-b'); + await ResourceEdge.create({ parentId: a.id, childId: b.id, relation: 'hosts' }); + await ResourceEdge.create({ parentId: b.id, childId: a.id, relation: 'hosts' }); + + await expect(Resource.findAncestorSiteSlug(a.id)).resolves.toBeNull(); + }); +}); diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index c1b17aa..d3ca838 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -78,252 +78,269 @@ - - -