Skip to content

Commit 10a9181

Browse files
authored
feat: security log for 403 errors (#120)
1 parent 7f48342 commit 10a9181

File tree

3 files changed

+98
-1
lines changed

3 files changed

+98
-1
lines changed

test/api/api.test.js

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
const cds = require('@sap/cds')
22

3-
const { POST, GET } = cds.test().in(__dirname)
3+
const { axios, POST, GET } = cds.test().in(__dirname)
4+
5+
// do not throw for 4xx responses
6+
axios.defaults.validateStatus = () => true
47

58
cds.env.requires['audit-log'] = {
69
kind: 'audit-log-to-console',
@@ -22,6 +25,7 @@ describe('AuditLogService API', () => {
2225
}
2326

2427
const ALICE = { username: 'alice', password: 'password' }
28+
const BOB = { username: 'bob', password: 'password' }
2529

2630
beforeAll(() => {
2731
__log = global.console.log
@@ -135,4 +139,43 @@ describe('AuditLogService API', () => {
135139
})
136140
})
137141
})
142+
143+
describe('custom log 403', () => {
144+
test('early reject', async () => {
145+
const response = await GET('/api/Books', { auth: BOB })
146+
expect(response).toMatchObject({ status: 403 })
147+
expect(_logs.length).toBe(1)
148+
expect(_logs).toContainMatchObject({ user: 'bob', ip: '::1' })
149+
})
150+
151+
test('late reject', async () => {
152+
const response = await GET('/api/Books', { auth: ALICE })
153+
expect(response).toMatchObject({ status: 403 })
154+
expect(_logs.length).toBe(1)
155+
expect(_logs).toContainMatchObject({ user: 'alice', ip: '::1' })
156+
})
157+
158+
test('early reject in batch', async () => {
159+
const response = await POST(
160+
'/api/$batch',
161+
{ requests: [{ method: 'GET', url: '/Books', id: 'r1' }] },
162+
{ auth: BOB }
163+
)
164+
expect(response).toMatchObject({ status: 403 })
165+
expect(_logs.length).toBeGreaterThan(0) //> coding in ./srv/server.js results in 2 logs on @sap/cds^7
166+
expect(_logs).toContainMatchObject({ user: 'bob', ip: '::1' })
167+
})
168+
169+
test('late reject in batch', async () => {
170+
const response = await POST(
171+
'/api/$batch',
172+
{ requests: [{ method: 'GET', url: '/Books', id: 'r1' }] },
173+
{ auth: ALICE }
174+
)
175+
expect(response).toMatchObject({ status: 200 })
176+
expect(response.data.responses[0]).toMatchObject({ status: 403 })
177+
expect(_logs.length).toBe(1)
178+
expect(_logs).toContainMatchObject({ user: 'alice', ip: '::1' })
179+
})
180+
})
138181
})

test/api/srv/api-service.cds

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
@path: '/api'
2+
@requires: 'admin'
23
service APIService {
34

45
// default
@@ -9,6 +10,12 @@ service APIService {
910
action testLog();
1011
action testLogSync();
1112

13+
@requires: 'cds.ExtensionDeveloper'
14+
entity Books {
15+
key ID : Integer;
16+
title : String;
17+
}
18+
1219
// test helpers
1320
function getSequence() returns many String;
1421
action resetSequence();

test/api/srv/server.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
const cds = require('@sap/cds')
2+
3+
let audit
4+
5+
cds.on('served', async () => {
6+
audit = await cds.connect.to('audit-log')
7+
})
8+
9+
const audit_log_403 = (resource, ip) => {
10+
// we need to start our own tx because the default tx may be burnt
11+
audit.tx(async () => {
12+
await audit.log('SecurityEvent', {
13+
data: {
14+
user: cds.context.user?.id || 'unknown',
15+
action: `Attempt to access restricted resource "${resource}" with insufficient authority`
16+
},
17+
ip
18+
})
19+
})
20+
}
21+
22+
// log for requests that are rejected with 403
23+
cds.on('bootstrap', app => {
24+
app.use((req, res, next) => {
25+
req.on('close', () => {
26+
if (res.statusCode == 403) {
27+
const { originalUrl, ip } = req
28+
audit_log_403(originalUrl, ip)
29+
}
30+
})
31+
next()
32+
})
33+
})
34+
35+
// log for batch subrequests that are rejected with 403 (but the batch request itself is successful)
36+
cds.on('serving', srv => {
37+
if (srv instanceof cds.ApplicationService) {
38+
srv.on('error', (err, req) => {
39+
if (err.code == 403) {
40+
const { originalUrl, ip } = req.http.req
41+
if (originalUrl.endsWith('/$batch')) audit_log_403(originalUrl.replace('/$batch', req.req.url), ip)
42+
}
43+
})
44+
}
45+
})
46+
47+
module.exports = cds.server

0 commit comments

Comments
 (0)