@rpcbase/server 0.288.0 → 0.289.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/boot/shared.js +1 -1
- package/package.json +1 -1
- package/src/access-control/access-control.schema.json +21 -0
- package/src/access-control/check_apply_permissions.js +92 -0
- package/src/access-control/default-access-control.json +14 -0
- package/src/access-control/get_added_fields.js +23 -0
- package/src/access-control/get_config.js +34 -0
- package/src/access-control/hooks/doc_pre_create.js +26 -0
- package/src/access-control/hooks/query_pre_delete.js +29 -0
- package/src/access-control/index.js +9 -0
- package/src/access-control/mongoose_plugin.js +131 -0
package/boot/shared.js
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://schemas.rpcbase.com/schemas/aceess-control.schema.json",
|
|
4
|
+
"title": "access-control",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"prop1": {
|
|
8
|
+
"type": "string",
|
|
9
|
+
"description": "The person's first name."
|
|
10
|
+
},
|
|
11
|
+
"lastName": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"description": "The person's last name."
|
|
14
|
+
},
|
|
15
|
+
"age": {
|
|
16
|
+
"description": "Age in years which must be equal to or greater than zero.",
|
|
17
|
+
"type": "integer",
|
|
18
|
+
"minimum": 0
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/* @flow */
|
|
2
|
+
const _get = require("lodash/get")
|
|
3
|
+
const colors = require("picocolors")
|
|
4
|
+
|
|
5
|
+
const user_types = ["owner", "user", "any"]
|
|
6
|
+
const perm_operations = ["create", "read", "update", "delete"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
// Owner
|
|
10
|
+
// the creator of the document, or an user_id that is in the "owners" field
|
|
11
|
+
|
|
12
|
+
// User
|
|
13
|
+
// any authenticated user that belongs in this tenant
|
|
14
|
+
|
|
15
|
+
// Any
|
|
16
|
+
// most relaxed permission, any client can access the resource
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
// given ac config, operation and document / query,
|
|
20
|
+
// check if the user has the permission to run this op
|
|
21
|
+
// if the user doesn't have permission, the system should throw an error
|
|
22
|
+
const check_apply_permissions = (ac_config, model_name, operation, user_id, item) => {
|
|
23
|
+
const rule_target = _get(ac_config, `${model_name}.${operation}`)
|
|
24
|
+
// console.log("rule target", rule_target)
|
|
25
|
+
|
|
26
|
+
// console.log("check_apply_permissions", `'${model_name}:${operation}:${rule_target}'`)
|
|
27
|
+
|
|
28
|
+
if (!rule_target) {
|
|
29
|
+
throw new Error(`undefined rule_target for '${model_name}:${operation}'`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!user_types.includes(rule_target)) {
|
|
33
|
+
throw new Error(`expected rule_target '${model_name}:${operation}' to be in '${JSON.stringify(user_types)}', but got '${rule_target}'`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Create
|
|
37
|
+
if (operation === "create") {
|
|
38
|
+
if (["owner", "user"].includes(rule_target)) {
|
|
39
|
+
if (!user_id) return new Error("create expected authenticated user_id")
|
|
40
|
+
} else {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Read
|
|
45
|
+
else if (operation === "read") {
|
|
46
|
+
const query = item
|
|
47
|
+
if (rule_target === "owner") {
|
|
48
|
+
if (!user_id) throw new Error("read::owner invalid user_id")
|
|
49
|
+
|
|
50
|
+
const conditions = query.getQuery()
|
|
51
|
+
|
|
52
|
+
if (conditions._owners) {
|
|
53
|
+
console.log(colors.yellow("Warning!"), model_name, "ACL for read is owner, but client is sending _owners in query, the field will be overwritten")
|
|
54
|
+
}
|
|
55
|
+
// TODO: implement dynamic ACL based on what the application allows the user to read
|
|
56
|
+
query.setQuery({
|
|
57
|
+
...conditions,
|
|
58
|
+
_owners: {$in: [user_id]}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Update
|
|
64
|
+
else if (operation === "update") {
|
|
65
|
+
const doc = item
|
|
66
|
+
if (rule_target === "owner") {
|
|
67
|
+
if (!doc._owners.includes(user_id)) {
|
|
68
|
+
// TODO: add debug logging
|
|
69
|
+
return new Error("expected user_id to be in owners to update document")
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
throw new Error(`unknown rule target '${rule_target}' for operation ${model_name}::update`)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Delete
|
|
76
|
+
else if (operation === "delete") {
|
|
77
|
+
const doc = item
|
|
78
|
+
if (rule_target === "owner") {
|
|
79
|
+
if (!doc._owners.includes(user_id)) {
|
|
80
|
+
// TODO: add debug logging
|
|
81
|
+
return new Error("expected user_id to be in owners to delete document")
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
throw new Error(`unknown rule target '${rule_target}' for operation ${model_name}::delete`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
module.exports = check_apply_permissions
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/* @flow */
|
|
2
|
+
|
|
3
|
+
const get_added_fields = () => {
|
|
4
|
+
|
|
5
|
+
return {
|
|
6
|
+
_owners: {
|
|
7
|
+
type: Array,
|
|
8
|
+
of: String,
|
|
9
|
+
index: true,
|
|
10
|
+
required: true,
|
|
11
|
+
},
|
|
12
|
+
_viewers: [String],
|
|
13
|
+
_editors: [String],
|
|
14
|
+
_created_by: String,
|
|
15
|
+
_created_at: {
|
|
16
|
+
type: Date
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
module.exports = get_added_fields
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/* @flow */
|
|
2
|
+
const path = require("path")
|
|
3
|
+
const fs = require("fs")
|
|
4
|
+
|
|
5
|
+
const default_config = require("./default-access-control.json")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
const validate_config = (models_dir, config) => {
|
|
9
|
+
// for each key in config, check if model exists
|
|
10
|
+
// check if config parameters also exist
|
|
11
|
+
console.log("access-control:NYI: validate config", models_dir)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
// this assumes the project is ran from the server/server/ directory
|
|
16
|
+
const get_config = (custom_path) => {
|
|
17
|
+
const config_path = custom_path || path.join(process.cwd(), "./src/models/access-control.json")
|
|
18
|
+
|
|
19
|
+
if (!fs.existsSync(config_path)) {
|
|
20
|
+
console.log("config path does not exist", config_path)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const conf_str = fs.readFileSync(config_path, "utf8")
|
|
24
|
+
const config = JSON.parse(conf_str)
|
|
25
|
+
|
|
26
|
+
const models_dir = path.dirname(config_path)
|
|
27
|
+
validate_config(models_dir, config)
|
|
28
|
+
|
|
29
|
+
return Object.assign(config, default_config)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
module.exports = get_config
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/* @flow */
|
|
2
|
+
//
|
|
3
|
+
// const has_permission = require("../has_permission")
|
|
4
|
+
//
|
|
5
|
+
// const delay = (time) => new Promise((resolve) => setTimeout(resolve, time))
|
|
6
|
+
// // await delay(2000)
|
|
7
|
+
//
|
|
8
|
+
// module.exports = (ac_config, schema) => async function(next) {
|
|
9
|
+
// const model_name = this.model.modelName
|
|
10
|
+
// const operation = "delete"
|
|
11
|
+
//
|
|
12
|
+
// if (this.op !== "findOneAndDelete") {
|
|
13
|
+
// throw new Error(`in pre_delete unknown operation: ${this.op}`)
|
|
14
|
+
// }
|
|
15
|
+
//
|
|
16
|
+
// const filter = this.getFilter()
|
|
17
|
+
// const doc = await this.model.findOne(filter)
|
|
18
|
+
//
|
|
19
|
+
// console.log("DELETE GOT DOC", doc)
|
|
20
|
+
// // console.log("THIS", this)
|
|
21
|
+
// // console.log("THIS filter", this.getFilter())
|
|
22
|
+
// // console.log("SCHEMA DEL", schema)
|
|
23
|
+
// // TODO: find a way to check if user can delete this document without reading it
|
|
24
|
+
// // console.log("delete got ac config", ac_config)
|
|
25
|
+
// next()
|
|
26
|
+
// }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* @flow */
|
|
2
|
+
|
|
3
|
+
const check_apply_permissions = require("../check_apply_permissions")
|
|
4
|
+
|
|
5
|
+
// const delay = (time) => new Promise((resolve) => setTimeout(resolve, time))
|
|
6
|
+
// await delay(2000)
|
|
7
|
+
|
|
8
|
+
module.exports = (ac_config, schema) => async function(next) {
|
|
9
|
+
const model_name = this.model.modelName
|
|
10
|
+
const operation = "delete"
|
|
11
|
+
|
|
12
|
+
if (this.op !== "findOneAndDelete") {
|
|
13
|
+
throw new Error(`in pre_delete unknown operation: ${this.op}`)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const user_id = this.options.ctx.req.session.user_id
|
|
17
|
+
|
|
18
|
+
const filter = this.getFilter()
|
|
19
|
+
const doc = await this.model.findOne(filter)
|
|
20
|
+
|
|
21
|
+
// check if user has permission to delete
|
|
22
|
+
const err = check_apply_permissions(ac_config, model_name, "delete", user_id, doc)
|
|
23
|
+
if (err) {
|
|
24
|
+
console.error(err)
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
next()
|
|
29
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/* @flow */
|
|
2
|
+
const debug = require("debug")
|
|
3
|
+
|
|
4
|
+
const get_added_fields = require("./get_added_fields")
|
|
5
|
+
const check_apply_permissions = require("./check_apply_permissions")
|
|
6
|
+
|
|
7
|
+
// hooks
|
|
8
|
+
const query_pre_delete = require("./hooks/query_pre_delete")
|
|
9
|
+
|
|
10
|
+
const log = debug("rb")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
const QUERY = {document: false, query: true}
|
|
14
|
+
const DOC = {document: true, query: false}
|
|
15
|
+
|
|
16
|
+
const get_query_middleware = (op) => (ac_config, schema) => function(next, save_options) {
|
|
17
|
+
|
|
18
|
+
// TODO: this is wrong (AND BREAKS ACL)
|
|
19
|
+
// when no save options, it's a sub schema, we don't want acl on those
|
|
20
|
+
// if (!save_options) {
|
|
21
|
+
// next()
|
|
22
|
+
// console.log("has returned")
|
|
23
|
+
// return
|
|
24
|
+
// }
|
|
25
|
+
|
|
26
|
+
const model_name = this.model.modelName
|
|
27
|
+
if (!model_name) throw new Error("cannot find model_name for query")
|
|
28
|
+
|
|
29
|
+
const options = this.getOptions()
|
|
30
|
+
const user_id = options.ctx?.req?.session?.user_id
|
|
31
|
+
|
|
32
|
+
// client requests should always be authenticated
|
|
33
|
+
if (options.is_client && !user_id) {
|
|
34
|
+
throw new Error("expected user_id in client request")
|
|
35
|
+
}
|
|
36
|
+
// skip if no ctx::user_id (=> is from admin)
|
|
37
|
+
else if (!user_id) {
|
|
38
|
+
log("mongoose_plugin: NO USER ID, skipping")
|
|
39
|
+
return next()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const err = check_apply_permissions(ac_config, model_name, "read", user_id, this)
|
|
43
|
+
if (err) {
|
|
44
|
+
console.warn(err)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
log("access-control will continue")
|
|
49
|
+
next()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
// https://mongoosejs.com/docs/middleware.html#types-of-middleware
|
|
54
|
+
const mongoose_plugin = (ac_config) => function rb_acl_plugin(schema, options) {
|
|
55
|
+
schema.options.strict = false
|
|
56
|
+
|
|
57
|
+
// TODO:
|
|
58
|
+
// DANGER: strictQuery to true silently DROPS filter params
|
|
59
|
+
// which can be a critical security risk
|
|
60
|
+
schema.options.strictQuery = false
|
|
61
|
+
|
|
62
|
+
if (!schema.options.isSubSchema) {
|
|
63
|
+
// Add Access Control fields to top level schemas
|
|
64
|
+
schema.add(get_added_fields())
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Queries
|
|
68
|
+
schema.pre("find", QUERY, get_query_middleware("find")(ac_config, schema))
|
|
69
|
+
schema.pre("findOne", QUERY, get_query_middleware("findOne")(ac_config, schema))
|
|
70
|
+
// TODO: add countDocuments, estimatedDocumentCount
|
|
71
|
+
// aggregate
|
|
72
|
+
|
|
73
|
+
schema.pre("findOneAndDelete", QUERY, query_pre_delete(ac_config, schema))
|
|
74
|
+
|
|
75
|
+
// Documents create and save
|
|
76
|
+
schema.pre("save", DOC, function(next, options) {
|
|
77
|
+
if (this.$isSubdocument) {
|
|
78
|
+
return next()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const model_name = this.constructor.modelName
|
|
82
|
+
if (!model_name) {
|
|
83
|
+
throw new Error("doc pre save model_name is undefined")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const {ctx} = options
|
|
87
|
+
|
|
88
|
+
// when no context, assume admin mode and authorize op
|
|
89
|
+
if (!ctx) {
|
|
90
|
+
this._created_at = new Date
|
|
91
|
+
next()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const user_id = ctx.req.session?.user_id
|
|
95
|
+
|
|
96
|
+
const doc = this
|
|
97
|
+
// Create
|
|
98
|
+
// TODO: apply create fields
|
|
99
|
+
if (this.isNew) {
|
|
100
|
+
const err = check_apply_permissions(ac_config, model_name, "create", user_id, doc)
|
|
101
|
+
if (err) {
|
|
102
|
+
console.warn(err)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if (!this._owners.includes(user_id)) {
|
|
106
|
+
this._owners.push(user_id)
|
|
107
|
+
}
|
|
108
|
+
this._created_by = user_id
|
|
109
|
+
this._created_at = new Date
|
|
110
|
+
next()
|
|
111
|
+
}
|
|
112
|
+
// Update
|
|
113
|
+
else {
|
|
114
|
+
const err = check_apply_permissions(ac_config, model_name, "update", user_id, doc)
|
|
115
|
+
if (err) {
|
|
116
|
+
console.warn(err)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
// TODO: apply update fields
|
|
120
|
+
console.log("warning, update check permission not verified")
|
|
121
|
+
next()
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
schema.pre("remove", DOC, function(next) {
|
|
126
|
+
console.log("schema pre REMOVE", this)
|
|
127
|
+
next()
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = mongoose_plugin
|