meyi-vault-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -0
- package/dist/index.mjs +37038 -0
- package/package.json +33 -0
- package/src/database/migrate.mjs +110 -0
- package/src/database/schema.mjs +89 -0
- package/src/index.mjs +139 -0
- package/src/routes/audit.mjs +75 -0
- package/src/routes/domains.mjs +80 -0
- package/src/routes/entries.mjs +170 -0
- package/src/routes/grants.mjs +141 -0
- package/src/routes/groups.mjs +127 -0
- package/src/routes/stats.mjs +50 -0
- package/src/routes/vaults.mjs +156 -0
- package/src/utils/audit.mjs +57 -0
- package/src/utils/auth.mjs +60 -0
- package/src/utils/crypto.mjs +97 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { Router } from 'express'
|
|
2
|
+
import { eq, and, isNull } from 'drizzle-orm'
|
|
3
|
+
import { vaultVaults, vaultGroups, vaultEntries } from '../database/schema.mjs'
|
|
4
|
+
import { encrypt, decrypt } from '../utils/crypto.mjs'
|
|
5
|
+
import { checkVaultAccess } from '../utils/auth.mjs'
|
|
6
|
+
import { writeAudit, actorFromReq } from '../utils/audit.mjs'
|
|
7
|
+
|
|
8
|
+
async function getVaultForGroup(db, groupId) {
|
|
9
|
+
const [group] = await db.select().from(vaultGroups).where(eq(vaultGroups.id, groupId))
|
|
10
|
+
if (!group) throw Object.assign(new Error('Group not found'), { status: 404 })
|
|
11
|
+
const [vault] = await db.select().from(vaultVaults).where(eq(vaultVaults.id, group.vault_id))
|
|
12
|
+
if (!vault) throw Object.assign(new Error('Vault not found'), { status: 404 })
|
|
13
|
+
return vault
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createEntriesRouter(db) {
|
|
17
|
+
const router = Router({ mergeParams: true })
|
|
18
|
+
|
|
19
|
+
// GET /groups/:groupId/entries — decrypt all entries
|
|
20
|
+
router.get('/', async (req, res) => {
|
|
21
|
+
try {
|
|
22
|
+
const vault = await getVaultForGroup(db, req.params.groupId)
|
|
23
|
+
const { access } = await checkVaultAccess(db, vault.id, req.user)
|
|
24
|
+
if (!access) return res.status(403).json({ error: 'Forbidden' })
|
|
25
|
+
|
|
26
|
+
const keyHex = vault.key_material
|
|
27
|
+
const rows = await db.select().from(vaultEntries).where(
|
|
28
|
+
and(eq(vaultEntries.group_id, req.params.groupId), isNull(vaultEntries.deleted_at))
|
|
29
|
+
)
|
|
30
|
+
const entries = rows.map(e => {
|
|
31
|
+
const { password, notes = '' } = JSON.parse(
|
|
32
|
+
decrypt(e.encrypted_data, e.iv, e.auth_tag, keyHex)
|
|
33
|
+
)
|
|
34
|
+
return {
|
|
35
|
+
id: e.id, group_id: e.group_id, title: e.title,
|
|
36
|
+
username: e.username, password, notes,
|
|
37
|
+
created_at: e.created_at, updated_at: e.updated_at,
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// Log sensitive access
|
|
42
|
+
if (entries.length > 0) {
|
|
43
|
+
await writeAudit(db, {
|
|
44
|
+
...actorFromReq(req),
|
|
45
|
+
action: 'ENTRIES_VIEWED',
|
|
46
|
+
module: 'entry',
|
|
47
|
+
item_label: `Viewed ${entries.length} entries in group ${req.params.groupId}`,
|
|
48
|
+
meta: { group_id: req.params.groupId, vault_id: vault.id }
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
res.json({ entries })
|
|
53
|
+
} catch (err) {
|
|
54
|
+
res.status(err.status || 500).json({ error: err.message || 'Failed to fetch entries' })
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// POST /groups/:groupId/entries — encrypt and store
|
|
59
|
+
router.post('/', async (req, res) => {
|
|
60
|
+
try {
|
|
61
|
+
const vault = await getVaultForGroup(db, req.params.groupId)
|
|
62
|
+
const { access, isOwner } = await checkVaultAccess(db, vault.id, req.user, true)
|
|
63
|
+
if (!access || !isOwner) return res.status(403).json({ error: 'Forbidden' })
|
|
64
|
+
|
|
65
|
+
const { title, username, password, notes = '' } = req.body
|
|
66
|
+
if (!title || !username || !password) {
|
|
67
|
+
return res.status(400).json({ error: 'title, username and password required' })
|
|
68
|
+
}
|
|
69
|
+
const keyHex = vault.key_material
|
|
70
|
+
const enc = encrypt(JSON.stringify({ password, notes }), keyHex)
|
|
71
|
+
const [entry] = await db.insert(vaultEntries).values({
|
|
72
|
+
group_id: req.params.groupId,
|
|
73
|
+
title: title.trim(),
|
|
74
|
+
username: username.trim(),
|
|
75
|
+
...enc,
|
|
76
|
+
}).returning()
|
|
77
|
+
|
|
78
|
+
await writeAudit(db, {
|
|
79
|
+
...actorFromReq(req),
|
|
80
|
+
action: 'ENTRY_CREATED',
|
|
81
|
+
module: 'entry',
|
|
82
|
+
item_id: entry.id,
|
|
83
|
+
item_label: entry.title,
|
|
84
|
+
meta: { group_id: req.params.groupId, vault_id: vault.id }
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
res.status(201).json({ entry: { ...entry, password, notes } })
|
|
88
|
+
} catch (err) {
|
|
89
|
+
res.status(err.status || 500).json({ error: err.message || 'Failed to create entry' })
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// PUT /groups/:groupId/entries/:id — re-encrypt on update
|
|
94
|
+
router.put('/:id', async (req, res) => {
|
|
95
|
+
try {
|
|
96
|
+
const vault = await getVaultForGroup(db, req.params.groupId)
|
|
97
|
+
const { access, isOwner } = await checkVaultAccess(db, vault.id, req.user, true)
|
|
98
|
+
if (!access || !isOwner) return res.status(403).json({ error: 'Forbidden' })
|
|
99
|
+
|
|
100
|
+
const [existing] = await db.select().from(vaultEntries).where(
|
|
101
|
+
and(
|
|
102
|
+
eq(vaultEntries.id, req.params.id),
|
|
103
|
+
eq(vaultEntries.group_id, req.params.groupId),
|
|
104
|
+
isNull(vaultEntries.deleted_at)
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
if (!existing) return res.status(404).json({ error: 'Entry not found' })
|
|
108
|
+
|
|
109
|
+
const keyHex = vault.key_material
|
|
110
|
+
const old = JSON.parse(decrypt(existing.encrypted_data, existing.iv, existing.auth_tag, keyHex))
|
|
111
|
+
|
|
112
|
+
const merged = {
|
|
113
|
+
password: req.body.password ?? old.password,
|
|
114
|
+
notes: req.body.notes ?? old.notes ?? '',
|
|
115
|
+
}
|
|
116
|
+
const enc = encrypt(JSON.stringify(merged), keyHex)
|
|
117
|
+
|
|
118
|
+
const [entry] = await db.update(vaultEntries).set({
|
|
119
|
+
title: req.body.title ?? existing.title,
|
|
120
|
+
username: req.body.username ?? existing.username,
|
|
121
|
+
...enc,
|
|
122
|
+
updated_at: new Date(),
|
|
123
|
+
}).where(eq(vaultEntries.id, req.params.id)).returning()
|
|
124
|
+
|
|
125
|
+
await writeAudit(db, {
|
|
126
|
+
...actorFromReq(req),
|
|
127
|
+
action: 'ENTRY_UPDATED',
|
|
128
|
+
module: 'entry',
|
|
129
|
+
item_id: entry.id,
|
|
130
|
+
item_label: entry.title,
|
|
131
|
+
meta: { group_id: req.params.groupId, vault_id: vault.id }
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
res.json({ entry: { ...entry, ...merged } })
|
|
135
|
+
} catch (err) {
|
|
136
|
+
res.status(err.status || 500).json({ error: err.message || 'Failed to update entry' })
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// DELETE /groups/:groupId/entries/:id — soft delete only
|
|
141
|
+
router.delete('/:id', async (req, res) => {
|
|
142
|
+
try {
|
|
143
|
+
const vault = await getVaultForGroup(db, req.params.groupId)
|
|
144
|
+
const { access, isOwner } = await checkVaultAccess(db, vault.id, req.user, true)
|
|
145
|
+
if (!access || !isOwner) return res.status(403).json({ error: 'Forbidden' })
|
|
146
|
+
|
|
147
|
+
const [entry] = await db.select().from(vaultEntries).where(eq(vaultEntries.id, req.params.id))
|
|
148
|
+
if (!entry) return res.status(404).json({ error: 'Entry not found' })
|
|
149
|
+
|
|
150
|
+
await db.update(vaultEntries).set({ deleted_at: new Date() }).where(
|
|
151
|
+
and(eq(vaultEntries.id, req.params.id), eq(vaultEntries.group_id, req.params.groupId))
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
await writeAudit(db, {
|
|
155
|
+
...actorFromReq(req),
|
|
156
|
+
action: 'ENTRY_DELETED',
|
|
157
|
+
module: 'entry',
|
|
158
|
+
item_id: req.params.id,
|
|
159
|
+
item_label: entry.title,
|
|
160
|
+
meta: { group_id: req.params.groupId, vault_id: vault.id }
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
res.json({ ok: true })
|
|
164
|
+
} catch (err) {
|
|
165
|
+
res.status(500).json({ error: 'Failed to delete entry' })
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
return router
|
|
170
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { Router } from 'express'
|
|
2
|
+
import { eq, and, isNull } from 'drizzle-orm'
|
|
3
|
+
import { vaultGrants } from '../database/schema.mjs'
|
|
4
|
+
import { expiresInToDate } from '../utils/crypto.mjs'
|
|
5
|
+
import { checkVaultAccess, getVaultIdForScope } from '../utils/auth.mjs'
|
|
6
|
+
import { writeAudit, actorFromReq } from '../utils/audit.mjs'
|
|
7
|
+
|
|
8
|
+
export function createGrantsRouter(db) {
|
|
9
|
+
const router = Router()
|
|
10
|
+
|
|
11
|
+
// POST /grants — create or renew a grant
|
|
12
|
+
router.post('/', async (req, res) => {
|
|
13
|
+
try {
|
|
14
|
+
const { scope, scope_id, grantee_id, expires_in } = req.body
|
|
15
|
+
if (!scope || !scope_id || !grantee_id) {
|
|
16
|
+
return res.status(400).json({ error: 'scope, scope_id and grantee_id required' })
|
|
17
|
+
}
|
|
18
|
+
if (!['vault', 'group', 'entry'].includes(scope)) {
|
|
19
|
+
return res.status(400).json({ error: 'scope must be vault, group, or entry' })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Check if grantor is the owner of the vault containing this scope
|
|
23
|
+
const vaultId = await getVaultIdForScope(db, scope, scope_id)
|
|
24
|
+
if (!vaultId) return res.status(404).json({ error: 'Resource not found' })
|
|
25
|
+
|
|
26
|
+
const { access, isOwner } = await checkVaultAccess(db, vaultId, req.user, true)
|
|
27
|
+
if (!access || !isOwner) return res.status(403).json({ error: 'Forbidden' })
|
|
28
|
+
|
|
29
|
+
const expires_at = expiresInToDate(expires_in)
|
|
30
|
+
|
|
31
|
+
// Upsert — if grant already exists (even revoked), renew it
|
|
32
|
+
const [grant] = await db
|
|
33
|
+
.insert(vaultGrants)
|
|
34
|
+
.values({
|
|
35
|
+
scope,
|
|
36
|
+
scope_id,
|
|
37
|
+
grantee_id,
|
|
38
|
+
granted_by_id: req.user.id,
|
|
39
|
+
expires_at,
|
|
40
|
+
revoked_at: null,
|
|
41
|
+
})
|
|
42
|
+
.onConflictDoUpdate({
|
|
43
|
+
target: [vaultGrants.scope, vaultGrants.scope_id, vaultGrants.grantee_id],
|
|
44
|
+
set: {
|
|
45
|
+
granted_by_id: req.user.id,
|
|
46
|
+
expires_at,
|
|
47
|
+
revoked_at: null,
|
|
48
|
+
created_at: new Date(),
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
.returning()
|
|
52
|
+
|
|
53
|
+
await writeAudit(db, {
|
|
54
|
+
...actorFromReq(req),
|
|
55
|
+
action: 'GRANT_CREATED',
|
|
56
|
+
module: 'grant',
|
|
57
|
+
item_id: grant.id,
|
|
58
|
+
item_label: `Access to ${scope} granted to ${grantee_id}`,
|
|
59
|
+
meta: { scope, scope_id, grantee_id, vault_id: vaultId }
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
res.status(201).json({ grant })
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.error('[vault] POST /grants', err.message)
|
|
65
|
+
res.status(500).json({ error: 'Failed to create grant' })
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// GET /grants — list grants issued by current user
|
|
70
|
+
router.get('/', async (req, res) => {
|
|
71
|
+
try {
|
|
72
|
+
const grants = await db
|
|
73
|
+
.select()
|
|
74
|
+
.from(vaultGrants)
|
|
75
|
+
.where(
|
|
76
|
+
and(
|
|
77
|
+
eq(vaultGrants.granted_by_id, req.user.id),
|
|
78
|
+
isNull(vaultGrants.revoked_at)
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
res.json({ grants })
|
|
82
|
+
} catch (err) {
|
|
83
|
+
res.status(500).json({ error: 'Failed to fetch grants' })
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// GET /grants/received — grants received by current user
|
|
88
|
+
router.get('/received', async (req, res) => {
|
|
89
|
+
try {
|
|
90
|
+
const grants = await db
|
|
91
|
+
.select()
|
|
92
|
+
.from(vaultGrants)
|
|
93
|
+
.where(
|
|
94
|
+
and(
|
|
95
|
+
eq(vaultGrants.grantee_id, req.user.id),
|
|
96
|
+
isNull(vaultGrants.revoked_at)
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
res.json({ grants })
|
|
100
|
+
} catch (err) {
|
|
101
|
+
res.status(500).json({ error: 'Failed to fetch received grants' })
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// DELETE /grants/:id — revoke
|
|
106
|
+
router.delete('/:id', async (req, res) => {
|
|
107
|
+
try {
|
|
108
|
+
const [grant] = await db
|
|
109
|
+
.select()
|
|
110
|
+
.from(vaultGrants)
|
|
111
|
+
.where(eq(vaultGrants.id, req.params.id))
|
|
112
|
+
|
|
113
|
+
if (!grant) return res.status(404).json({ error: 'Grant not found' })
|
|
114
|
+
|
|
115
|
+
// Only the grantor or an admin can revoke
|
|
116
|
+
if (grant.granted_by_id !== req.user.id && req.user.role !== 'admin') {
|
|
117
|
+
return res.status(403).json({ error: 'Forbidden' })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await db
|
|
121
|
+
.update(vaultGrants)
|
|
122
|
+
.set({ revoked_at: new Date() })
|
|
123
|
+
.where(eq(vaultGrants.id, req.params.id))
|
|
124
|
+
|
|
125
|
+
await writeAudit(db, {
|
|
126
|
+
...actorFromReq(req),
|
|
127
|
+
action: 'GRANT_REVOKED',
|
|
128
|
+
module: 'grant',
|
|
129
|
+
item_id: grant.id,
|
|
130
|
+
item_label: `Access to ${grant.scope} revoked from ${grant.grantee_id}`,
|
|
131
|
+
meta: { scope: grant.scope, scope_id: grant.scope_id, grantee_id: grant.grantee_id }
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
res.json({ ok: true })
|
|
135
|
+
} catch (err) {
|
|
136
|
+
res.status(500).json({ error: 'Failed to revoke grant' })
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
return router
|
|
141
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Router } from 'express'
|
|
2
|
+
import { eq, and, isNull, sql } from 'drizzle-orm'
|
|
3
|
+
import { vaultGroups, vaultEntries } from '../database/schema.mjs'
|
|
4
|
+
import { checkVaultAccess } from '../utils/auth.mjs'
|
|
5
|
+
import { writeAudit, actorFromReq } from '../utils/audit.mjs'
|
|
6
|
+
|
|
7
|
+
export function createGroupsRouter(db) {
|
|
8
|
+
const router = Router({ mergeParams: true })
|
|
9
|
+
|
|
10
|
+
// GET /vaults/:vaultId/groups
|
|
11
|
+
router.get('/', async (req, res) => {
|
|
12
|
+
try {
|
|
13
|
+
const { access } = await checkVaultAccess(db, req.params.vaultId, req.user)
|
|
14
|
+
if (!access) return res.status(403).json({ error: 'Forbidden' })
|
|
15
|
+
|
|
16
|
+
const groups = await db.select().from(vaultGroups)
|
|
17
|
+
.where(eq(vaultGroups.vault_id, req.params.vaultId))
|
|
18
|
+
|
|
19
|
+
// Entry counts per group
|
|
20
|
+
const withCounts = await Promise.all(groups.map(async (g) => {
|
|
21
|
+
const [{ count }] = await db
|
|
22
|
+
.select({ count: sql`count(*)::int` })
|
|
23
|
+
.from(vaultEntries)
|
|
24
|
+
.where(and(eq(vaultEntries.group_id, g.id), isNull(vaultEntries.deleted_at)))
|
|
25
|
+
return { ...g, entry_count: count ?? 0 }
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
res.json({ groups: withCounts })
|
|
29
|
+
} catch (err) {
|
|
30
|
+
res.status(500).json({ error: 'Failed to fetch groups' })
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// POST /vaults/:vaultId/groups
|
|
35
|
+
router.post('/', async (req, res) => {
|
|
36
|
+
try {
|
|
37
|
+
const { access, isOwner } = await checkVaultAccess(db, req.params.vaultId, req.user, true)
|
|
38
|
+
if (!access || !isOwner) return res.status(403).json({ error: 'Forbidden' })
|
|
39
|
+
|
|
40
|
+
const { name, domain } = req.body
|
|
41
|
+
if (!name || !domain) return res.status(400).json({ error: 'name and domain required' })
|
|
42
|
+
const [group] = await db.insert(vaultGroups).values({
|
|
43
|
+
vault_id: req.params.vaultId,
|
|
44
|
+
name: name.trim(),
|
|
45
|
+
domain: domain.trim().toLowerCase(),
|
|
46
|
+
}).returning()
|
|
47
|
+
|
|
48
|
+
await writeAudit(db, {
|
|
49
|
+
...actorFromReq(req),
|
|
50
|
+
action: 'GROUP_CREATED',
|
|
51
|
+
module: 'group',
|
|
52
|
+
item_id: group.id,
|
|
53
|
+
item_label: group.name,
|
|
54
|
+
meta: { vault_id: req.params.vaultId }
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
res.status(201).json({ group: { ...group, entry_count: 0 } })
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err.code === '23505') {
|
|
60
|
+
return res.status(409).json({ error: 'This domain already exists in the vault' })
|
|
61
|
+
}
|
|
62
|
+
res.status(500).json({ error: 'Failed to create group' })
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// PUT /vaults/:vaultId/groups/:id
|
|
67
|
+
router.put('/:id', async (req, res) => {
|
|
68
|
+
try {
|
|
69
|
+
const { access, isOwner } = await checkVaultAccess(db, req.params.vaultId, req.user, true)
|
|
70
|
+
if (!access || !isOwner) return res.status(403).json({ error: 'Forbidden' })
|
|
71
|
+
|
|
72
|
+
const updates = {}
|
|
73
|
+
if (req.body.name) updates.name = req.body.name.trim()
|
|
74
|
+
if (req.body.domain) updates.domain = req.body.domain.trim().toLowerCase()
|
|
75
|
+
if (!Object.keys(updates).length) return res.status(400).json({ error: 'Nothing to update' })
|
|
76
|
+
|
|
77
|
+
const [group] = await db.update(vaultGroups).set(updates)
|
|
78
|
+
.where(and(eq(vaultGroups.id, req.params.id), eq(vaultGroups.vault_id, req.params.vaultId)))
|
|
79
|
+
.returning()
|
|
80
|
+
|
|
81
|
+
if (!group) return res.status(404).json({ error: 'Group not found' })
|
|
82
|
+
|
|
83
|
+
await writeAudit(db, {
|
|
84
|
+
...actorFromReq(req),
|
|
85
|
+
action: 'GROUP_UPDATED',
|
|
86
|
+
module: 'group',
|
|
87
|
+
item_id: group.id,
|
|
88
|
+
item_label: group.name,
|
|
89
|
+
meta: { vault_id: req.params.vaultId }
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
res.json({ group })
|
|
93
|
+
} catch (err) {
|
|
94
|
+
res.status(500).json({ error: 'Failed to update group' })
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
// DELETE /vaults/:vaultId/groups/:id
|
|
99
|
+
router.delete('/:id', async (req, res) => {
|
|
100
|
+
try {
|
|
101
|
+
const { access, isOwner } = await checkVaultAccess(db, req.params.vaultId, req.user, true)
|
|
102
|
+
if (!access || !isOwner) return res.status(403).json({ error: 'Forbidden' })
|
|
103
|
+
|
|
104
|
+
const [group] = await db.select().from(vaultGroups).where(eq(vaultGroups.id, req.params.id))
|
|
105
|
+
if (!group) return res.status(404).json({ error: 'Group not found' })
|
|
106
|
+
|
|
107
|
+
await db.delete(vaultGroups).where(
|
|
108
|
+
and(eq(vaultGroups.id, req.params.id), eq(vaultGroups.vault_id, req.params.vaultId))
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
await writeAudit(db, {
|
|
112
|
+
...actorFromReq(req),
|
|
113
|
+
action: 'GROUP_DELETED',
|
|
114
|
+
module: 'group',
|
|
115
|
+
item_id: req.params.id,
|
|
116
|
+
item_label: group.name,
|
|
117
|
+
meta: { vault_id: req.params.vaultId }
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
res.json({ ok: true })
|
|
121
|
+
} catch (err) {
|
|
122
|
+
res.status(500).json({ error: 'Failed to delete group' })
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
return router
|
|
127
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Router } from 'express'
|
|
2
|
+
import { eq, and, isNull, sql } from 'drizzle-orm'
|
|
3
|
+
import { vaultVaults, vaultGrants, vaultEntries } from '../database/schema.mjs'
|
|
4
|
+
|
|
5
|
+
export function createStatsRouter(db) {
|
|
6
|
+
const router = Router()
|
|
7
|
+
|
|
8
|
+
// GET /stats — dashboard widget data for current user
|
|
9
|
+
router.get('/', async (req, res) => {
|
|
10
|
+
try {
|
|
11
|
+
const userId = req.user.id
|
|
12
|
+
|
|
13
|
+
const [{ vault_count }] = await db
|
|
14
|
+
.select({ vault_count: sql`count(*)::int` })
|
|
15
|
+
.from(vaultVaults)
|
|
16
|
+
.where(eq(vaultVaults.owner_id, userId))
|
|
17
|
+
|
|
18
|
+
const [{ active_grants }] = await db
|
|
19
|
+
.select({ active_grants: sql`count(*)::int` })
|
|
20
|
+
.from(vaultGrants)
|
|
21
|
+
.where(
|
|
22
|
+
and(
|
|
23
|
+
eq(vaultGrants.granted_by_id, userId),
|
|
24
|
+
isNull(vaultGrants.revoked_at)
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const [{ entry_count }] = await db
|
|
29
|
+
.select({ entry_count: sql`count(*)::int` })
|
|
30
|
+
.from(vaultEntries)
|
|
31
|
+
.where(isNull(vaultEntries.deleted_at))
|
|
32
|
+
|
|
33
|
+
res.json({
|
|
34
|
+
vault_count: vault_count ?? 0,
|
|
35
|
+
active_grants: active_grants ?? 0,
|
|
36
|
+
entry_count: entry_count ?? 0,
|
|
37
|
+
})
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error('[vault] GET /stats', err.message)
|
|
40
|
+
res.status(500).json({ error: 'Failed to fetch stats' })
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// GET /me — current user info
|
|
45
|
+
router.get('/me', (req, res) => {
|
|
46
|
+
res.json({ user: req.user })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
return router
|
|
50
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Router } from 'express'
|
|
2
|
+
import { eq, and, isNull, or, sql, gt } from 'drizzle-orm'
|
|
3
|
+
import { vaultVaults, vaultGrants } from '../database/schema.mjs'
|
|
4
|
+
import { generateVaultKey } from '../utils/crypto.mjs'
|
|
5
|
+
import { checkVaultAccess, getVaultIdForScope } from '../utils/auth.mjs'
|
|
6
|
+
import { writeAudit, actorFromReq } from '../utils/audit.mjs'
|
|
7
|
+
|
|
8
|
+
export function createVaultsRouter(db) {
|
|
9
|
+
const router = Router()
|
|
10
|
+
|
|
11
|
+
// GET /vaults — list owned + shared vaults
|
|
12
|
+
router.get('/', async (req, res) => {
|
|
13
|
+
try {
|
|
14
|
+
const userId = req.user.id
|
|
15
|
+
const owned = await db.select().from(vaultVaults).where(eq(vaultVaults.owner_id, userId))
|
|
16
|
+
|
|
17
|
+
// Find all active grants for the user
|
|
18
|
+
const activeGrants = await db.select().from(vaultGrants).where(
|
|
19
|
+
and(
|
|
20
|
+
eq(vaultGrants.grantee_id, userId),
|
|
21
|
+
isNull(vaultGrants.revoked_at),
|
|
22
|
+
or(
|
|
23
|
+
isNull(vaultGrants.expires_at),
|
|
24
|
+
gt(vaultGrants.expires_at, sql`now()`)
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
// Resolve vault IDs for all grants
|
|
30
|
+
const vaultIds = new Set()
|
|
31
|
+
for (const grant of activeGrants) {
|
|
32
|
+
if (grant.scope === 'vault') {
|
|
33
|
+
vaultIds.add(grant.scope_id)
|
|
34
|
+
} else {
|
|
35
|
+
const vId = await getVaultIdForScope(db, grant.scope, grant.scope_id)
|
|
36
|
+
if (vId) vaultIds.add(vId)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let shared = []
|
|
41
|
+
const activeGrantIds = Array.from(vaultIds)
|
|
42
|
+
if (activeGrantIds.length > 0) {
|
|
43
|
+
shared = await db.select().from(vaultVaults).where(
|
|
44
|
+
sql`${vaultVaults.id} = ANY(ARRAY[${sql.raw(activeGrantIds.map(id => `'${id}'`).join(','))}]::uuid[])`
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ownedIds = new Set(owned.map(v => v.id))
|
|
49
|
+
const all = [
|
|
50
|
+
...owned.map(v => ({ ...v, is_owner: true })),
|
|
51
|
+
...shared.filter(v => !ownedIds.has(v.id)).map(v => ({ ...v, is_owner: false })),
|
|
52
|
+
].map(({ key_material, ...safe }) => safe) // never send key to client
|
|
53
|
+
|
|
54
|
+
res.json({ vaults: all })
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.error('[vault] GET /vaults', err.message)
|
|
57
|
+
res.status(500).json({ error: 'Failed to fetch vaults' })
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// POST /vaults — create vault with fresh AES key
|
|
62
|
+
router.post('/', async (req, res) => {
|
|
63
|
+
try {
|
|
64
|
+
const { name } = req.body
|
|
65
|
+
if (!name?.trim()) return res.status(400).json({ error: 'name required' })
|
|
66
|
+
const [vault] = await db.insert(vaultVaults).values({
|
|
67
|
+
name: name.trim(),
|
|
68
|
+
owner_id: req.user.id,
|
|
69
|
+
key_material: generateVaultKey(),
|
|
70
|
+
}).returning()
|
|
71
|
+
|
|
72
|
+
await writeAudit(db, {
|
|
73
|
+
...actorFromReq(req),
|
|
74
|
+
action: 'VAULT_CREATED',
|
|
75
|
+
module: 'vault',
|
|
76
|
+
item_id: vault.id,
|
|
77
|
+
item_label: vault.name,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const { key_material, ...safe } = vault
|
|
81
|
+
res.status(201).json({ vault: { ...safe, is_owner: true } })
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error('[vault] POST /vaults', err.message)
|
|
84
|
+
res.status(500).json({ error: 'Failed to create vault' })
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// GET /vaults/:id
|
|
89
|
+
router.get('/:id', async (req, res) => {
|
|
90
|
+
try {
|
|
91
|
+
const { access, isOwner, vault } = await checkVaultAccess(db, req.params.id, req.user)
|
|
92
|
+
if (!vault) return res.status(404).json({ error: 'Vault not found' })
|
|
93
|
+
if (!access) return res.status(403).json({ error: 'Forbidden' })
|
|
94
|
+
|
|
95
|
+
const { key_material, ...safe } = vault
|
|
96
|
+
res.json({ vault: { ...safe, is_owner: isOwner } })
|
|
97
|
+
} catch (err) {
|
|
98
|
+
res.status(500).json({ error: 'Failed to fetch vault' })
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// PUT /vaults/:id
|
|
103
|
+
router.put('/:id', async (req, res) => {
|
|
104
|
+
try {
|
|
105
|
+
const { name } = req.body
|
|
106
|
+
if (!name?.trim()) return res.status(400).json({ error: 'name required' })
|
|
107
|
+
|
|
108
|
+
const { access, isOwner, vault } = await checkVaultAccess(db, req.params.id, req.user, true)
|
|
109
|
+
if (!vault) return res.status(404).json({ error: 'Vault not found' })
|
|
110
|
+
if (!access) return res.status(403).json({ error: 'Forbidden' })
|
|
111
|
+
|
|
112
|
+
const [updated] = await db.update(vaultVaults)
|
|
113
|
+
.set({ name: name.trim() })
|
|
114
|
+
.where(eq(vaultVaults.id, req.params.id))
|
|
115
|
+
.returning()
|
|
116
|
+
|
|
117
|
+
await writeAudit(db, {
|
|
118
|
+
...actorFromReq(req),
|
|
119
|
+
action: 'VAULT_UPDATED',
|
|
120
|
+
module: 'vault',
|
|
121
|
+
item_id: updated.id,
|
|
122
|
+
item_label: updated.name,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const { key_material, ...safe } = updated
|
|
126
|
+
res.json({ vault: { ...safe, is_owner: isOwner } })
|
|
127
|
+
} catch (err) {
|
|
128
|
+
res.status(500).json({ error: 'Failed to update vault' })
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// DELETE /vaults/:id
|
|
133
|
+
router.delete('/:id', async (req, res) => {
|
|
134
|
+
try {
|
|
135
|
+
const { access, vault } = await checkVaultAccess(db, req.params.id, req.user, true)
|
|
136
|
+
if (!vault) return res.status(404).json({ error: 'Vault not found' })
|
|
137
|
+
if (!access) return res.status(403).json({ error: 'Forbidden' })
|
|
138
|
+
|
|
139
|
+
await db.delete(vaultVaults).where(eq(vaultVaults.id, req.params.id))
|
|
140
|
+
|
|
141
|
+
await writeAudit(db, {
|
|
142
|
+
...actorFromReq(req),
|
|
143
|
+
action: 'VAULT_DELETED',
|
|
144
|
+
module: 'vault',
|
|
145
|
+
item_id: req.params.id,
|
|
146
|
+
item_label: vault.name,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
res.json({ ok: true })
|
|
150
|
+
} catch (err) {
|
|
151
|
+
res.status(500).json({ error: 'Failed to delete vault' })
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
return router
|
|
156
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { vaultAuditLogs } from '../database/schema.mjs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Audit logging utility.
|
|
5
|
+
* Every mutating action in vault-server calls writeAudit().
|
|
6
|
+
* Errors are swallowed so an audit failure never breaks the main request.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {object} db — Drizzle DB instance
|
|
11
|
+
* @param {object} params
|
|
12
|
+
* @param {string} params.actor_id
|
|
13
|
+
* @param {string} params.actor_name
|
|
14
|
+
* @param {string} params.actor_email
|
|
15
|
+
* @param {string} params.action — e.g. 'VAULT_CREATED'
|
|
16
|
+
* @param {string} params.module — 'vault' | 'group' | 'entry' | 'grant'
|
|
17
|
+
* @param {string} params.item_id — UUID of the resource
|
|
18
|
+
* @param {string} params.item_label — human name e.g. vault name, entry title
|
|
19
|
+
* @param {object} [params.meta] — extra context
|
|
20
|
+
*/
|
|
21
|
+
export async function writeAudit(db, {
|
|
22
|
+
actor_id,
|
|
23
|
+
actor_name = '',
|
|
24
|
+
actor_email = '',
|
|
25
|
+
action,
|
|
26
|
+
module,
|
|
27
|
+
item_id = '',
|
|
28
|
+
item_label = '',
|
|
29
|
+
meta = null,
|
|
30
|
+
}) {
|
|
31
|
+
try {
|
|
32
|
+
await db.insert(vaultAuditLogs).values({
|
|
33
|
+
actor_id,
|
|
34
|
+
actor_name,
|
|
35
|
+
actor_email,
|
|
36
|
+
action,
|
|
37
|
+
module,
|
|
38
|
+
item_id: String(item_id),
|
|
39
|
+
item_label,
|
|
40
|
+
meta: meta ? JSON.stringify(meta) : null,
|
|
41
|
+
})
|
|
42
|
+
} catch (err) {
|
|
43
|
+
// Audit failures must never break the main request path
|
|
44
|
+
console.error('[vault-audit] write failed:', err.message)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Convenience: extract actor fields from req.user
|
|
50
|
+
*/
|
|
51
|
+
export function actorFromReq(req) {
|
|
52
|
+
return {
|
|
53
|
+
actor_id: req.user?.id ?? 'unknown',
|
|
54
|
+
actor_name: req.user?.full_name ?? req.user?.name ?? '',
|
|
55
|
+
actor_email: req.user?.email ?? '',
|
|
56
|
+
}
|
|
57
|
+
}
|