From aad908f4deb012c804947778878cbdddb05bccd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ot=C3=A1vio=20Jacobi?= Date: Wed, 26 Jul 2023 15:32:55 -0300 Subject: [PATCH] Add /actor/v1/whoami endpoint Change-type: minor --- src/features/auth/index.ts | 3 +- src/features/auth/whoami.ts | 165 ++++++++++++++++++++- test/04_session.ts | 123 ++++++++++++++- test/fixtures/04-session/applications.json | 7 + test/fixtures/04-session/devices.json | 8 + 5 files changed, 302 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/04-session/applications.json create mode 100644 test/fixtures/04-session/devices.json diff --git a/src/features/auth/index.ts b/src/features/auth/index.ts index b5fa2f28e..71197e95c 100644 --- a/src/features/auth/index.ts +++ b/src/features/auth/index.ts @@ -10,7 +10,7 @@ import { middleware } from '../../infra/auth'; import { login } from './login'; import { getUserPublicKeys } from './public-keys'; import { refreshToken } from './refresh-token'; -import { whoami } from './whoami'; +import { whoami, actorWhoami } from './whoami'; export * from './handles'; export { refreshToken }; @@ -28,6 +28,7 @@ export const setup = (app: Application, onLogin: SetupOptions['onLogin']) => { app.post('/login_', loginRateLimiter('body.username'), login(onLogin)); app.get('/user/v1/whoami', middleware.fullyAuthenticatedUser, whoami); + app.get('/actor/v1/whoami', middleware.authenticated, actorWhoami); app.get( '/auth/v1/public-keys/:username', diff --git a/src/features/auth/whoami.ts b/src/features/auth/whoami.ts index a4b3169ad..6079a3ae4 100644 --- a/src/features/auth/whoami.ts +++ b/src/features/auth/whoami.ts @@ -3,12 +3,58 @@ import type { RequestHandler } from 'express'; import { sbvrUtils, permissions } from '@balena/pinejs'; import { getUser } from '../../infra/auth/auth'; -import { captureException, handleHttpErrors } from '../../infra/error-handling'; +import { + captureException, + handleHttpErrors, + ThisShouldNeverHappenError, +} from '../../infra/error-handling'; -import type { User } from '../../balena-model'; +import type { User, Application, Device } from '../../balena-model'; +import { UnauthorizedError } from '@balena/pinejs/out/sbvr-api/errors'; const { api } = sbvrUtils; +type ExpandedActor = + | { + id: number; + is_of__user: [Pick]; + is_of__application: []; + is_of__device: []; + } + | { + id: number; + is_of__user: []; + is_of__application: [Pick]; + is_of__device: []; + } + | { + id: number; + is_of__user: []; + is_of__application: []; + is_of__device: [Pick]; + }; + +type ActorResponse = + | { + id: number; + actorType: 'user'; + actorTypeId: number; + username: string; + email: string | null; + } + | { + id: number; + actorType: 'application'; + actorTypeId: number; + slug: string; + } + | { + id: number; + actorType: 'device'; + actorTypeId: number; + uuid: string; + }; + export const whoami: RequestHandler = async (req, res) => { try { const userInfo = await sbvrUtils.db.readTransaction(async (tx) => { @@ -62,3 +108,118 @@ export const whoami: RequestHandler = async (req, res) => { res.status(500).end(); } }; + +export const actorWhoami: RequestHandler = async (req, res) => { + try { + const actorInfo = await sbvrUtils.db.readTransaction(async (tx) => { + // If this is a user key/token we must validate this is a key that + // has permissions for reading username/email + if (req.user?.actor) { + const [userWithId] = (await api.resin.get({ + resource: 'user', + passthrough: { req, tx }, + options: { + $top: 1, + $select: 'id', + $filter: { + actor: req.user?.actor, + }, + }, + })) as Array>; + + if (userWithId == null) { + throw new UnauthorizedError(); + } + } + + const actorId = req.apiKey?.actor ?? req.user?.actor; + + if (actorId == null) { + throw new UnauthorizedError( + 'Request API Key or Token has no associated actor', + ); + } + + return (await api.resin.get({ + resource: 'actor', + passthrough: { req: permissions.root, tx }, + id: actorId, + options: { + $expand: { + is_of__user: { + $select: ['id', 'username', 'email'], + }, + is_of__application: { + $select: ['id', 'slug'], + }, + is_of__device: { + $select: ['id', 'uuid'], + }, + }, + }, + })) as ExpandedActor; + }); + res.json(formatActorInfo(actorInfo)); + } catch (err) { + if (handleHttpErrors(req, res, err)) { + return; + } + captureException(err, 'Error while getting actor info', { req }); + res.status(500).end(); + } +}; + +const formatActorInfo = (rawActorInfo: ExpandedActor): ActorResponse => { + validateRawActorInfo(rawActorInfo); + + if (rawActorInfo.is_of__user.length === 1) { + return { + id: rawActorInfo.id, + actorType: 'user', + actorTypeId: rawActorInfo.is_of__user[0].id, + username: rawActorInfo.is_of__user[0].username, + email: rawActorInfo.is_of__user[0].email, + }; + } + + if (rawActorInfo.is_of__application.length === 1) { + return { + id: rawActorInfo.id, + actorType: 'application', + actorTypeId: rawActorInfo.is_of__application[0].id, + slug: rawActorInfo.is_of__application[0].slug, + }; + } + + if (rawActorInfo.is_of__device.length === 1) { + return { + id: rawActorInfo.id, + actorType: 'device', + actorTypeId: rawActorInfo.is_of__device[0].id, + uuid: rawActorInfo.is_of__device[0].uuid, + }; + } + + throw ThisShouldNeverHappenError( + `Found ${rawActorInfo.id} associated with none or more than one resource`, + ); +}; + +const validateRawActorInfo = (rawActorInfo: ExpandedActor) => { + const amountAssociatedResources = + rawActorInfo.is_of__user.length + + rawActorInfo.is_of__application.length + + rawActorInfo.is_of__device.length; + + if (amountAssociatedResources > 1) { + throw ThisShouldNeverHappenError( + `Found ${rawActorInfo.id} associated with more than one resource`, + ); + } + + if (amountAssociatedResources < 1) { + throw new UnauthorizedError( + `Actor ${rawActorInfo.id} is not associated to any resource`, + ); + } +}; diff --git a/test/04_session.ts b/test/04_session.ts index 31e318c44..4bfa62f38 100644 --- a/test/04_session.ts +++ b/test/04_session.ts @@ -5,16 +5,47 @@ import { createScopedAccessToken } from '../src/infra/auth/jwt'; import * as fixtures from './test-lib/fixtures'; import { supertest, UserObjectParam } from './test-lib/supertest'; import { version } from './test-lib/versions'; +import { Device } from './test-lib/fake-device'; +import { Application } from '../src/balena-model'; const atob = (x: string) => Buffer.from(x, 'base64').toString('binary'); const parseJwt = (t: string) => JSON.parse(atob(t.split('.')[1])); describe('session', () => { let admin: UserObjectParam; + let device: Device; + let application: Application; + let deviceApiKey: string; + let provisioningKey: string; + let userApiKey: string; before(async function () { - const fx = await fixtures.load(); + const fx = await fixtures.load('04-session'); + this.loadedFixtures = fx; admin = fx.users.admin; + device = fx.devices.device1; + application = fx.applications.app1; + + const { body: deviceKeyBody } = await supertest(admin).post( + `/api-key/device/${device.id}/device-key`, + ); + deviceApiKey = deviceKeyBody; + + const { body: appKeyBody } = await supertest(admin).post( + `/api-key/application/${application.id}/provisioning`, + ); + provisioningKey = appKeyBody; + + const { body: userKeyBody } = await supertest(admin) + .post('/api-key/user/full') + .send({ name: 'actorwhoamitest' }); + + userApiKey = userKeyBody; + }); + + after(async function () { + await supertest(admin).delete(`/${version}/api_key`).expect(200); + await fixtures.clean(this.loadedFixtures); }); it('/user/v1/whoami returns a user', async function () { @@ -164,4 +195,94 @@ describe('session', () => { .expect(401); }); }); + + it('/actor/v1/whoami returns an actor for an user token', async function () { + const userActor = ( + await supertest(admin).get('/actor/v1/whoami').expect(200) + ).body; + + expect(userActor).to.have.property('id').that.is.a('number'); + expect(userActor.actorType).to.equal('user'); + expect(userActor.actorTypeId).to.equal(admin.id); + expect(userActor.username).to.equal('admin'); + expect(userActor.email).to.equal(SUPERUSER_EMAIL); + }); + + it('/actor/v1/whoami returns an actor for an user api key', async function () { + const userActor = ( + await supertest(userApiKey).get('/actor/v1/whoami').expect(200) + ).body; + + expect(userActor).to.have.property('id').that.is.a('number'); + expect(userActor.actorType).to.equal('user'); + expect(userActor.actorTypeId).to.equal(admin.id); + expect(userActor.username).to.equal('admin'); + expect(userActor.email).to.equal(SUPERUSER_EMAIL); + }); + + it('/actor/v1/whoami returns an actor for a device api key', async function () { + const deviceActor = ( + await supertest(deviceApiKey).get('/actor/v1/whoami').expect(200) + ).body; + + expect(deviceActor).to.have.property('id').that.is.a('number'); + expect(deviceActor.actorType).to.equal('device'); + expect(deviceActor.actorTypeId).to.equal(device.id); + expect(deviceActor.uuid).to.equal(device.uuid); + }); + + it('/actor/v1/whoami returns an actor for an application api key', async function () { + const appActor = ( + await supertest(provisioningKey).get('/actor/v1/whoami').expect(200) + ).body; + + expect(appActor).to.have.property('id').that.is.a('number'); + expect(appActor.actorType).to.equal('application'); + expect(appActor.actorTypeId).to.equal(application.id); + expect(appActor.slug).to.equal(application.slug); + }); + + it('/actor/v1/whoami returns a user when using a correctly scoped access token', async function () { + const record = ( + await supertest(admin) + .get(`/${version}/user?$filter=username eq 'admin'`) + .expect(200) + ).body.d[0]; + + // Create a token that only has access to the granting users document + const accessToken = createScopedAccessToken({ + actor: record.actor.__id, + permissions: ['resin.user.read?actor eq @__ACTOR_ID'], + expiresIn: 60 * 10, + }); + + const userActor = ( + await supertest(accessToken).get('/actor/v1/whoami').expect(200) + ).body; + + expect(userActor).to.have.property('id').that.is.a('number'); + expect(userActor.actorType).to.equal('user'); + expect(userActor.actorTypeId).to.equal(admin.id); + expect(userActor.username).to.equal('admin'); + expect(userActor.email).to.equal(SUPERUSER_EMAIL); + }); + + it('/actor/v1/whoami returns a 401 error when using a scoped access token that does not have user permissions', async function () { + const record = ( + await supertest(admin) + .get(`/${version}/user?$filter=username eq 'admin'`) + .expect(200) + ).body.d[0]; + + const permissions = ['resin.application.read?actor eq @__ACTOR_ID']; + + // Create a token that only has access to the granting users applications + const accessToken = createScopedAccessToken({ + actor: record.actor.__id, + permissions, + expiresIn: 60 * 10, + }); + + await supertest(accessToken).get('/actor/v1/whoami').expect(401); + }); }); diff --git a/test/fixtures/04-session/applications.json b/test/fixtures/04-session/applications.json new file mode 100644 index 000000000..91e29839f --- /dev/null +++ b/test/fixtures/04-session/applications.json @@ -0,0 +1,7 @@ +{ + "app1": { + "user": "admin", + "app_name": "test_actor_whoami", + "device_type": "raspberry-pi" + } +} diff --git a/test/fixtures/04-session/devices.json b/test/fixtures/04-session/devices.json new file mode 100644 index 000000000..cf7919a8f --- /dev/null +++ b/test/fixtures/04-session/devices.json @@ -0,0 +1,8 @@ +{ + "device1": { + "belongs_to__application": "app1", + "device_type": "raspberry-pi", + "belongs_to__user": "admin", + "uuid": "c47e4dec05f76ee37c4b8e805c35c1eee54335803b3a40458928f8afce618b" + } +}