Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions api/src/limits/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ export const getOrgLimits = async (org: Organization) => {
return limit
}

// NHIs are not members: findMembers excludes them by default, this query has to do the same
// NHIs count as members here: they hold an org membership like humans do and consume the same quota,
// even though findMembers hides them by default
const getNbMembers = async (orgId: string) => {
return mongo.users.countDocuments({ 'organizations.id': orgId, plannedDeletion: { $exists: false }, nhi: { $exists: false } })
return mongo.users.countDocuments({ 'organizations.id': orgId, plannedDeletion: { $exists: false } })
}

export const setNbMembersLimit = async (orgId: string) => {
Expand Down
11 changes: 9 additions & 2 deletions api/src/nhis/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import eventsLog from '@data-fair/lib-express/events-log.js'
import config from '#config'
import storages from '#storages'
import { reqI18n } from '#i18n'
import { postUserIdentityWebhook, deleteIdentityWebhook } from '#services'
import { postUserIdentityWebhook, deleteIdentityWebhook, getOrgLimits, setNbMembersLimit } from '#services'
import { isOrgAdmin } from '../organizations/service.ts'
import { checkProvider } from './keys.ts'
import { checkAllowedIps } from './ips.ts'
Expand Down Expand Up @@ -43,9 +43,15 @@ router.post('', async (req: Request<OrgParams>, res) => {
if (!roles.includes(body.role)) throw httpError(400, 'unknown role')
await checkProvider(body.provider)
if (body.allowedIps) checkAllowedIps(body.allowedIps)
logContext.account = { type: 'organization', id: org.id, name: org.name }
const limits = await getOrgLimits(org)
if (limits.store_nb_members.limit > 0 && limits.store_nb_members.consumption >= limits.store_nb_members.limit) {
eventsLog.info('sd.nhi.limit', `limit error for NHI creation in org ${org.id}`, logContext)
throw httpError(429, reqI18n(req).messages.errors.maxNbMembers)
}
const sessionUser = reqSessionAuthenticated(req).user
const user = await createNhi(org, body, { id: sessionUser.id, name: sessionUser.name })
logContext.account = { type: 'organization', id: org.id, name: org.name }
await setNbMembersLimit(org.id)
eventsLog.info('sd.nhi.create', `an NHI was created ${user.id} in org ${org.id}`, logContext)
postUserIdentityWebhook(user)
res.status(201).send(user)
Expand Down Expand Up @@ -111,6 +117,7 @@ router.delete('/:nhiId', async (req: Request<NhiParams>, res) => {
const user = await getNhi(req.params.organizationId, req.params.nhiId)
logContext.account = { type: 'organization', id: req.params.organizationId, name: user.organizations[0]?.name }
await storages.globalStorage.deleteUser(user.id)
await setNbMembersLimit(req.params.organizationId)
eventsLog.info('sd.nhi.delete', `an NHI was deleted ${user.id}`, logContext)
deleteIdentityWebhook('user', user.id)
res.status(204).send()
Expand Down
15 changes: 9 additions & 6 deletions docs/architecture/non-human-identities.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,16 +369,19 @@ $exists: false }`). An NHI id passed as `:userId` 404s before the
`assertNotNhiSession` guard is even relevant — the guard covers the case of
an NHI *session* acting on someone else, the 404 gate covers an NHI as
*target*. `GET /api/organizations/:id/members` uses the same default, so
existing consumers (quota counts, invitation UIs, other services) see no
existing consumers (invitation UIs, other services) see no
behavior change unless they opt in with `?types=nhi` or
`?types=user,nhi`.

**`findMembers` is not the only place that has to exclude NHIs.**
**NHIs do count in the members quota.** Unlike `findMembers`,
`getNbMembers` (`api/src/limits/service.ts`), which feeds the
`store_nb_members` quota, counts the `users` collection directly instead of
going through `findMembers`, so it repeats the `nhi: { $exists: false }`
filter itself. Any new consumer that counts memberships with its own query
has to do the same.
`store_nb_members` quota, counts every membership in the `users` collection,
NHIs included: a service account holds an org membership and acts with its
role, so it consumes a member slot like a human does. NHI creation
(`POST /api/organizations/:organizationId/nhis`) is refused with a 429 when the
org is full, and both creation and deletion recompute the counter. The
consequence is that `store_nb_members.consumption` can exceed the `count` of
the default (human only) member listing.

**Cleanup jobs exclude NHIs.** `storage.findInactiveUsers` /
`findUsersToDelete` (`api/src/storages/mongo.ts`) filter NHIs out, so
Expand Down
30 changes: 16 additions & 14 deletions tests/features/nhis.api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,27 +356,29 @@ test('nhi provider is validated at create and patch time', async () => {
await ax.patch(`/api/organizations/${org.id}/nhis/${nhi.id}`, { name: 'Renamed validated agent' })
})

test('NHIs do not consume a member slot in the organization limits', async () => {
const { ax, user } = await createUser('nhi-limits@test.com')
test('NHIs consume a member slot in the organization limits', async () => {
const { ax } = await createUser('nhi-limits@test.com')
const org = (await ax.post('/api/organizations', { name: 'NHI limits org' })).data
ax.setOrg(org.id)
const { ax: adminAx } = await createUser('admin@test.com', true)
const getConsumption = async () => (await ax.get(`/api/limits/organization/${org.id}`)).data.store_nb_members.consumption

// reading the limits is what creates the denormalized counter
assert.equal((await ax.get(`/api/limits/organization/${org.id}`)).data.store_nb_members.consumption, 1)

await ax.post(`/api/organizations/${org.id}/nhis`, nhiBody())
assert.equal(await getConsumption(), 1)

// deleting the last human recomputes the counter: the NHI must not hold the org at 1 member,
// consistently with the member listing that excludes NHIs by default
const { ax: adminAx } = await createUser('admin@test.com', true)
await adminAx.delete(`/api/users/${user.id}`)
const nhi = (await ax.post(`/api/organizations/${org.id}/nhis`, nhiBody())).data
assert.equal(await getConsumption(), 2)

const orgLimits = (await adminAx.get('/api/limits', { params: { type: 'organization', id: org.id } })).data.results[0]
assert.equal(orgLimits.store_nb_members.consumption, 0)
// a full org refuses another NHI, like it refuses another invitation
await adminAx.post(`/api/limits/organization/${org.id}`, { store_nb_members: { limit: 2, consumption: 2 }, lastUpdate: new Date().toISOString() })
await assert.rejects(ax.post(`/api/organizations/${org.id}/nhis`, nhiBody({ name: 'Second agent' })), { status: 429 })
assert.equal((await ax.get(`/api/organizations/${org.id}/nhis`)).data.count, 1)

// the creator is deleted above, so DELETE /api/test-env cannot scope this org by created.id
// any more -- drop it here, or it accumulates across runs and pollutes name-based org searches
await adminAx.delete(`/api/organizations/${org.id}`)
// deleting the NHI frees its slot
await ax.delete(`/api/organizations/${org.id}/nhis/${nhi.id}`)
assert.equal(await getConsumption(), 1)
await ax.post(`/api/organizations/${org.id}/nhis`, nhiBody({ name: 'Second agent' }))
assert.equal(await getConsumption(), 2)
})

test('nhi token exchange is restricted to the declared allowedIps', async () => {
Expand Down
16 changes: 13 additions & 3 deletions ui/src/components/add-nhi-menu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,15 @@
<v-card-title>
{{ $t('pages.organization.addNhi') }}
</v-card-title>
<template v-if="editNhi">
<v-card-text v-if="disableCreate">
<v-alert
:value="true"
type="warning"
>
{{ $t('pages.organization.disableInvite') }}
</v-alert>
</v-card-text>
<template v-else-if="editNhi">
<v-card-text>
<v-form
ref="createForm"
Expand Down Expand Up @@ -138,8 +146,10 @@ import type { VForm } from 'vuetify/components'
const { sendUiNotif } = useUiNotif()
const { t } = useI18n()

const { orga } = defineProps({
orga: { type: Object as () => Organization, required: true }
const { orga, disableCreate } = defineProps({
orga: { type: Object as () => Organization, required: true },
// NHIs consume a member slot, so the members quota gates their creation too
disableCreate: { type: Boolean, default: false }
})
const { roleItems } = useRoleLabels(() => orga)
const emit = defineEmits(['change'])
Expand Down
20 changes: 16 additions & 4 deletions ui/src/components/organization-nhis.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
<add-nhi-menu
v-if="adminMode"
:orga="orga"
@change="fetchNhis.refresh()"
:disable-create="disableCreate"
@change="onChange"
/>
<v-tooltip location="right">
<template #activator="{props}">
Expand Down Expand Up @@ -92,7 +93,7 @@
<edit-nhi-menu
:orga="orga"
:nhi="nhi"
@change="fetchNhis.refresh()"
@change="onChange"
/>
</v-list-item-action>
<v-list-item-action
Expand All @@ -102,7 +103,7 @@
<delete-nhi-menu
:orga="orga"
:nhi="nhi"
@change="fetchNhis.refresh()"
@change="onChange"
/>
</v-list-item-action>
</template>
Expand All @@ -121,12 +122,17 @@ import { useClipboard } from '@vueuse/core'
// Progressive rollout: normal org admins get a read-only view, and only once at least
// one NHI exists (the whole section is hidden otherwise). Only superadmins see the
// create/edit/delete controls. This is a UI gate only — the API stays org-admin scoped.
const { orga } = defineProps({
const { orga, nbMembersLimits } = defineProps({
orga: {
type: Object as () => Organization,
required: true
},
nbMembersLimits: {
type: Object as () => { limit: number, consumption: number } | undefined,
default: undefined
}
})
const emit = defineEmits(['change'])
const { roleLabel } = useRoleLabels(() => orga)

const { copy } = useClipboard()
Expand All @@ -135,6 +141,12 @@ const adminMode = computed(() => !!session.user.value?.adminMode)

const fetchNhis = useFetch<{ count: number, results: any[] }>(`${$apiPath}/organizations/${orga.id}/nhis`)
const nhis = computed(() => fetchNhis.data.value)
const disableCreate = computed(() => !nbMembersLimits || (nbMembersLimits.limit > 0 && nbMembersLimits.consumption >= nbMembersLimits.limit))
// NHIs count in the members quota, let the parent refresh the limits it shares with the members list
const onChange = () => {
fetchNhis.refresh()
emit('change')
}
// cache-buster tied to list refreshes so an avatar uploaded through the edit dialog
// shows up on the next change event instead of the browser's cached image
const avatarsTimestamp = ref(Date.now())
Expand Down
2 changes: 2 additions & 0 deletions ui/src/pages/organization/[id]/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@
<organization-nhis
v-if="$uiConfig.manageNhis && orgRole === 'admin'"
:orga="orga"
:nb-members-limits="limits.data.value?.store_nb_members"
@change="limits.refresh()"
/>
</v-container>
</template>
Expand Down
Loading