Skip to content

Commit

Permalink
Merge pull request #1385 from balena-io/otaviojacobi/adds-actor-whoam…
Browse files Browse the repository at this point in the history
…i-endpoint

Add /actor/v1/whoami endpoint
  • Loading branch information
flowzone-app[bot] authored Aug 2, 2023
2 parents b95e336 + aad908f commit 2bbbadd
Show file tree
Hide file tree
Showing 5 changed files with 302 additions and 4 deletions.
3 changes: 2 additions & 1 deletion src/features/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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',
Expand Down
165 changes: 163 additions & 2 deletions src/features/auth/whoami.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<User, 'id' | 'username' | 'email'>];
is_of__application: [];
is_of__device: [];
}
| {
id: number;
is_of__user: [];
is_of__application: [Pick<Application, 'id' | 'slug'>];
is_of__device: [];
}
| {
id: number;
is_of__user: [];
is_of__application: [];
is_of__device: [Pick<Device, 'id' | 'uuid'>];
};

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) => {
Expand Down Expand Up @@ -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<Pick<User, 'id'>>;

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`,
);
}
};
123 changes: 122 additions & 1 deletion test/04_session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -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);
});
});
7 changes: 7 additions & 0 deletions test/fixtures/04-session/applications.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"app1": {
"user": "admin",
"app_name": "test_actor_whoami",
"device_type": "raspberry-pi"
}
}
8 changes: 8 additions & 0 deletions test/fixtures/04-session/devices.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"device1": {
"belongs_to__application": "app1",
"device_type": "raspberry-pi",
"belongs_to__user": "admin",
"uuid": "c47e4dec05f76ee37c4b8e805c35c1eee54335803b3a40458928f8afce618b"
}
}

0 comments on commit 2bbbadd

Please sign in to comment.