Skip to content

Commit

Permalink
Sign permit evaluation response with the client's key (#215)
Browse files Browse the repository at this point in the history
* Generate client signing key

* Attestate with client's key

* Rename tenant to client in the policy engine

* Fix tenant repository in the vault

* Merge schema and type files

Rename signer config for better reusability.
  • Loading branch information
wcalderipe authored Apr 2, 2024
1 parent 19b4085 commit ebdebbd
Show file tree
Hide file tree
Showing 26 changed files with 378 additions and 377 deletions.
4 changes: 2 additions & 2 deletions apps/docs/docs/contributing/policy-engine-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ battle-tested multi purpose engine trusted by many organizations. The OPA core
bunlded with the policy engine's artifact and it's considered a trusted
component.

The policy engine layer, is responsible for encrypting and decrypting tenant
The policy engine layer, is responsible for encrypting and decrypting client
data it stores. When the engine server starts, it writes data to its storage
backend. Since the storage backend lives outside the engine, it's considered
untrusted so the engine will encrypt the data before it sends them to the
Expand All @@ -39,6 +39,6 @@ instances.
Similar to the storage backend, the policy store and the entity store also live
outside and are considered untrusted. The key difference is that the engine
never writes to these stores, it only reads from them. The data in these stores
are hashed and signed by the tenant's private key. The engine uses the tenant's
are hashed and signed by the client's private key. The engine uses the client's
public key to verify the signature and hash. This mechanism ensures that the
data is authentic and has not been tampered with.
66 changes: 33 additions & 33 deletions apps/docs/docs/contributing/policy-engine-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

## Terminology

- **CEK**: Content Encryption Key. The key used to encrypt tenant-related data.
- **CEK**: Content Encryption Key. The key used to encrypt client-related data.
- **KEK**: Key Encryption Key. The key used to encrypt engine-related data.
- **MK**: Master Key
- **Data Storage**: The storage system responsible for holding data related to
tenants entities and policies. While Narval doesn't mandate the use of a
clients entities and policies. While Narval doesn't mandate the use of a
specific storage system like S3 or IPFS, it does require that the data stored
conforms to Narval's data structure for entities and policies.
- **Database**: The engine's database. It's used to store some of engine's
configuration, tenant's encrypted configuration, and encrypted tenant's data.
configuration, client's encrypted configuration, and encrypted client's data.
- **Engineer**: A persona with high credentials to set up critical software within
an organization.
- **Admin**: A persona responsible for managing the engine in the organization.
Expand Down Expand Up @@ -47,30 +47,30 @@ sequenceDiagram
end
```

## Sync tenant data stores
## Sync client data stores

Summary of the procedure to fetch tenant data. This is used at boot time and
when a new tenant is onboarded.
Summary of the procedure to fetch client data. This is used at boot time and
when a new client is onboarded.

```mermaid
sequenceDiagram
title Fetch tenant data
title Fetch client data
participant Engine
participant DS as Data Storage
participant DB as Database
par
Engine ->> DS: Read tenant entity data (ED)
Engine ->> DS: Read client entity data (ED)
activate Engine
Engine ->> DS: Read tenant entity signature (ES)
Engine ->> DS: Read tenant policy data (PD)
Engine ->> DS: Read tenant policy signature (PS)
Engine ->> DS: Read client entity signature (ES)
Engine ->> DS: Read client policy data (PD)
Engine ->> DS: Read client policy signature (PS)
end
Engine ->> Engine: Verify signatures
Engine ->> Engine: Check if tenant's data changed
Engine ->> Engine: Check if client's data changed
alt signatures are valid and data changed
Engine ->> DB: Write CEK (AES-256) encrypted tenant's ED, ES, PD, and PS
Engine ->> DB: Write CEK (AES-256) encrypted client's ED, ES, PD, and PS
deactivate Engine
end
```
Expand All @@ -92,54 +92,54 @@ sequenceDiagram
Engine ->> Engine: Read and validate engine's configuration
activate Engine
alt if engine configuration is valid
Engine ->> DB: Read tenants configuration
loop For each tenant
Engine ->> DS: Fetch tenant data
Engine ->> DB: Read clients configuration
loop For each client
Engine ->> DS: Fetch client data
end
else
Engine ->> Engine: Abort the boot with invalid environment error message
deactivate Engine
end
```

## Tenant onboard
## Client onboard

Summary of the procedure to onboard a new tenant in a live policy engine.
Summary of the procedure to onboard a new client in a live policy engine.

```mermaid
sequenceDiagram
title Tenant onboard
title client onboard
actor Admin
participant Engine as Policy Engine
participant DB as Database
participant DS as Data Storage
Admin ->> Engine: Onboard tenant request
Admin ->> Engine: Onboard client request
activate Engine
Engine ->> DB: Verify if admin API key exists
Engine ->> Engine: Generate tenant signing key pair
Engine ->> Engine: Generate tenant API key (TAK)
Engine ->> DB: Write CEK (AES-256) encrypted tenant configuration
Engine ->> DS: Fetch tenant data
Engine ->> DB: Write CEK (AES-256) tenant's data
Note over DB: Does not fail the onboarding if fetching the tenant data failed
Engine -->> Admin: Tenant's UID, sgining public key and TAK
Engine ->> Engine: Generate client signing key pair
Engine ->> Engine: Generate client API key (TAK)
Engine ->> DB: Write CEK (AES-256) encrypted client configuration
Engine ->> DS: Fetch client data
Engine ->> DB: Write CEK (AES-256) client's data
Note over DB: Does not fail the onboarding if fetching the client data failed
Engine -->> Admin: client's UID, sgining public key and TAK
deactivate Engine
```

- **Onboard Tenant Request**: Requires: Admin API key, Tenant ID, Entity storage
- **Onboard client Request**: Requires: Admin API key, client ID, Entity storage
URL, Entity signature URL, Entity JWKS, Policy storage URL, Policy signature
URL, Policy JWKS.
- **Content Encryption Key (CEK)**: A unique key made by blending a Master Key
and a Tenant UID with a process called HMAC Key Derivation Function (HKDF), used
and a client UID with a process called HMAC Key Derivation Function (HKDF), used
to encrypt data.
- **Admin Role**: Only an admin can add new tenants to the system.
- **Admin Role**: Only an admin can add new clients to the system.
- **Admin API Keys**: The system supports multiple API keys for admins, allowing
several admins to operate.
- **Tenant's Signing Key**: This key signs evaluation responses, ensuring
- **client's Signing Key**: This key signs evaluation responses, ensuring
they're genuine and allowing verification by upstream services.
- **Tenant API Key**: Used by tenants to authenticate their requests to the
- **client API Key**: Used by clients to authenticate their requests to the
Policy Engine, ensuring that the requests are legitimate. It's used alongside
user signed requests.

Expand All @@ -154,7 +154,7 @@ sequenceDiagram
participant DB as Database
participant FS as File System
Engine ->> Engine: Sync tenant policy data store
Engine ->> Engine: Sync client policy data store
Engine ->> DB: Get policy dataset latest version
Engine ->> Engine: Build Rego bundle
Engine ->> FS: Temporary save the Rego bundle
Expand Down
Original file line number Diff line number Diff line change
@@ -1,39 +1,42 @@
import { ConfigModule, ConfigService } from '@narval/config-module'
import { EncryptionModuleOptionProvider } from '@narval/encryption-module'
import { DataStoreConfiguration } from '@narval/policy-engine-shared'
import { secp256k1PrivateKeyToJwk } from '@narval/signature'
import {
PrivateKey,
privateKeyToHex,
secp256k1PrivateKeyToJwk,
secp256k1PrivateKeyToPublicJwk
} from '@narval/signature'
import { HttpStatus, INestApplication } from '@nestjs/common'
import { Test, TestingModule } from '@nestjs/testing'
import request from 'supertest'
import { v4 as uuid } from 'uuid'
import { generatePrivateKey } from 'viem/accounts'
import { EngineService } from '../../../engine/core/service/engine.service'
import { Config, load } from '../../../policy-engine.config'
import {
REQUEST_HEADER_API_KEY,
REQUEST_HEADER_CLIENT_ID,
REQUEST_HEADER_CLIENT_SECRET
} from '../../../policy-engine.constant'
import { KeyValueRepository } from '../../../shared/module/key-value/core/repository/key-value.repository'
import { InMemoryKeyValueRepository } from '../../../shared/module/key-value/persistence/repository/in-memory-key-value.repository'
import { TestPrismaService } from '../../../shared/module/persistence/service/test-prisma.service'
import { getTestRawAesKeyring } from '../../../shared/testing/encryption.testing'
import { Tenant } from '../../../shared/type/domain.type'
import { TenantService } from '../../core/service/tenant.service'
import { Client } from '../../../shared/type/domain.type'
import { ClientService } from '../../core/service/client.service'
import { EngineService } from '../../core/service/engine.service'
import { EngineModule } from '../../engine.module'
import { CreateTenantDto } from '../../http/rest/dto/create-tenant.dto'
import { TenantRepository } from '../../persistence/repository/tenant.repository'
import { CreateClientDto } from '../../http/rest/dto/create-client.dto'
import { ClientRepository } from '../../persistence/repository/client.repository'

describe('Tenant', () => {
describe('Client', () => {
let app: INestApplication
let module: TestingModule
let testPrismaService: TestPrismaService
let tenantRepository: TenantRepository
let tenantService: TenantService
let clientRepository: ClientRepository
let clientService: ClientService
let engineService: EngineService
let configService: ConfigService<Config>
let dataStoreConfiguration: DataStoreConfiguration
let createTenantPayload: CreateTenantDto
let createClientPayload: CreateClientDto

const adminApiKey = 'test-admin-api-key'

Expand All @@ -51,8 +54,6 @@ describe('Tenant', () => {
EngineModule
]
})
.overrideProvider(KeyValueRepository)
.useValue(new InMemoryKeyValueRepository())
.overrideProvider(EncryptionModuleOptionProvider)
.useValue({
keyring: getTestRawAesKeyring()
Expand All @@ -62,8 +63,8 @@ describe('Tenant', () => {
app = module.createNestApplication()

engineService = module.get<EngineService>(EngineService)
tenantService = module.get<TenantService>(TenantService)
tenantRepository = module.get<TenantRepository>(TenantRepository)
clientService = module.get<ClientService>(ClientService)
clientRepository = module.get<ClientRepository>(ClientRepository)
testPrismaService = module.get<TestPrismaService>(TestPrismaService)
configService = module.get<ConfigService<Config>>(ConfigService)

Expand All @@ -75,20 +76,12 @@ describe('Tenant', () => {
keys: [jwk]
}

createTenantPayload = {
createClientPayload = {
clientId,
entityDataStore: dataStoreConfiguration,
policyDataStore: dataStoreConfiguration
}

await testPrismaService.truncateAll()

await engineService.save({
id: configService.get('engine.id'),
masterKey: 'unsafe-test-master-key',
adminApiKey
})

await app.init()
})

Expand All @@ -98,59 +91,73 @@ describe('Tenant', () => {
await app.close()
})

beforeEach(() => {
jest.spyOn(tenantService, 'syncDataStore').mockResolvedValue(true)
beforeEach(async () => {
await testPrismaService.truncateAll()

await engineService.save({
id: configService.get('engine.id'),
masterKey: 'unsafe-test-master-key',
adminApiKey
})

jest.spyOn(clientService, 'syncDataStore').mockResolvedValue(true)
})

describe('POST /tenants', () => {
it('creates a new tenant', async () => {
describe('POST /clients', () => {
it('creates a new client', async () => {
const { status, body } = await request(app.getHttpServer())
.post('/tenants')
.post('/clients')
.set(REQUEST_HEADER_API_KEY, adminApiKey)
.send(createTenantPayload)
const actualTenant = await tenantRepository.findByClientId(clientId)
.send(createClientPayload)

const actualClient = await clientRepository.findByClientId(clientId)
const actualPublicKey = secp256k1PrivateKeyToPublicJwk(privateKeyToHex(actualClient?.signer.key as PrivateKey))

expect(body).toMatchObject({
clientId,
clientSecret: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
dataStore: {
policy: dataStoreConfiguration,
entity: dataStoreConfiguration
}
})
expect(body).toEqual({
...actualTenant,
createdAt: actualTenant?.createdAt.toISOString(),
updatedAt: actualTenant?.updatedAt.toISOString()
...actualClient,
signer: { publicKey: actualPublicKey },
createdAt: actualClient?.createdAt.toISOString(),
updatedAt: actualClient?.updatedAt.toISOString()
})
expect(status).toEqual(HttpStatus.CREATED)
})

it('does not expose the signer private key', async () => {
const { body } = await request(app.getHttpServer())
.post('/clients')
.set(REQUEST_HEADER_API_KEY, adminApiKey)
.send(createClientPayload)

expect(body.signer.key).not.toBeDefined()
expect(body.signer.type).not.toBeDefined()
// The JWK private key is stored in the key's `d` property.
// See also https://datatracker.ietf.org/doc/html/rfc7517#appendix-A.2
expect(body.signer.publicKey.d).not.toBeDefined()
})

it('responds with an error when clientId already exist', async () => {
await request(app.getHttpServer())
.post('/tenants')
.post('/clients')
.set(REQUEST_HEADER_API_KEY, adminApiKey)
.send(createTenantPayload)
.send(createClientPayload)

const { status, body } = await request(app.getHttpServer())
.post('/tenants')
.post('/clients')
.set(REQUEST_HEADER_API_KEY, adminApiKey)
.send(createTenantPayload)
.send(createClientPayload)

expect(body).toEqual({
message: 'Tenant already exist',
message: 'Client already exist',
statusCode: HttpStatus.BAD_REQUEST
})
expect(status).toEqual(HttpStatus.BAD_REQUEST)
})

it('responds with forbidden when admin api key is invalid', async () => {
const { status, body } = await request(app.getHttpServer())
.post('/tenants')
.post('/clients')
.set(REQUEST_HEADER_API_KEY, 'invalid-api-key')
.send(createTenantPayload)
.send(createClientPayload)

expect(body).toMatchObject({
message: 'Forbidden resource',
Expand All @@ -160,29 +167,29 @@ describe('Tenant', () => {
})
})

describe('POST /tenants/sync', () => {
let tenant: Tenant
describe('POST /clients/sync', () => {
let client: Client

beforeEach(async () => {
jest.spyOn(tenantService, 'syncDataStore').mockResolvedValue(true)
jest.spyOn(clientService, 'syncDataStore').mockResolvedValue(true)

const { body } = await request(app.getHttpServer())
.post('/tenants')
.post('/clients')
.set(REQUEST_HEADER_API_KEY, adminApiKey)
.send({
...createTenantPayload,
...createClientPayload,
clientId: uuid()
})

tenant = body
client = body
})

it('calls the tenant data store sync', async () => {
it('calls the client data store sync', async () => {
const { status, body } = await request(app.getHttpServer())
.post('/tenants/sync')
.set(REQUEST_HEADER_CLIENT_ID, tenant.clientId)
.set(REQUEST_HEADER_CLIENT_SECRET, tenant.clientSecret)
.send(createTenantPayload)
.post('/clients/sync')
.set(REQUEST_HEADER_CLIENT_ID, client.clientId)
.set(REQUEST_HEADER_CLIENT_SECRET, client.clientSecret)
.send(createClientPayload)

expect(body).toEqual({ ok: true })
expect(status).toEqual(HttpStatus.OK)
Expand Down
Loading

0 comments on commit ebdebbd

Please sign in to comment.