diff --git a/app/assets/app/dash/SideBar.component.js b/app/assets/app/dash/SideBar.component.js index 984232f..d239678 100644 --- a/app/assets/app/dash/SideBar.component.js +++ b/app/assets/app/dash/SideBar.component.js @@ -66,6 +66,12 @@ export default class SideBarComponent extends Component { type: 'resource', resource: 'iam/Permission', }, + { + text: 'Vaults', + action: 'list', + type: 'resource', + resource: 'vault/Vault', + }, { text: 'Computers', action: 'list', diff --git a/app/assets/app/resource/iam/Policy.resource.js b/app/assets/app/resource/iam/Policy.resource.js index 0750eb2..9179dce 100644 --- a/app/assets/app/resource/iam/Policy.resource.js +++ b/app/assets/app/resource/iam/Policy.resource.js @@ -129,6 +129,7 @@ class PolicyResource extends CRUDBase { {display: 'API Scope', value: 'api_scope'}, {display: 'Computer', value: 'machine'}, {display: 'Computer Group', value: 'machine_group'}, + {display: 'Vault', value: 'vault'}, ], }, { @@ -179,6 +180,18 @@ class PolicyResource extends CRUDBase { }, if: (form_data) => form_data.target_type === 'machine_group' }, + { + name: 'Target', + field: 'target_id', + required: true, + type: 'select.dynamic', + options: { + resource: 'vault/Vault', + display: 'name', + value: 'id', + }, + if: (form_data) => form_data.target_type === 'vault' + }, { name: 'Permission', field: 'permission', @@ -243,6 +256,22 @@ class PolicyResource extends CRUDBase { }, if: (form_data, opts) => form_data.target_type === 'machine_group' && opts?.length }, + { + name: 'Permission', + field: 'permission', + required: false, + type: 'select.dynamic', + options: { + resource: 'iam/Permission', + display: 'permission', + value: 'permission', + other_params: { + target_type: 'vault', + include_unset: true, + }, + }, + if: (form_data, opts) => form_data.target_type === 'vault' && opts?.length + }, ], /*handlers: { insert: { diff --git a/app/assets/app/resource/vault/Vault.resource.js b/app/assets/app/resource/vault/Vault.resource.js new file mode 100644 index 0000000..a3c9982 --- /dev/null +++ b/app/assets/app/resource/vault/Vault.resource.js @@ -0,0 +1,62 @@ +import CRUDBase from '../CRUDBase.js' + +class VaultResource extends CRUDBase { + constructor() { + super() + + this.endpoint = '/api/v1/vault/vaults' + this.required_fields = ['name'] + this.permission_base = 'v1:vault:vaults' + + this.item = 'Vault' + this.plural = 'Vaults' + + this.listing_definition = { + display: `Vaults are encrypted key-value stores that can be managed with IAM and accessed via REST APIs.`, + columns: [ + { + name: 'Name', + field: 'name', + }, + ], + actions: [ + { + type: 'resource', + position: 'main', + action: 'insert', + text: 'Create New', + color: 'success', + }, + { + type: 'resource', + position: 'row', + action: 'update', + icon: 'fa fa-edit', + color: 'primary', + }, + { + type: 'resource', + position: 'row', + action: 'delete', + icon: 'fa fa-times', + color: 'danger', + confirm: true, + }, + ], + } + + this.form_definition = { + fields: [ + { + name: 'Name', + field: 'name', + required: true, + type: 'text', + }, + ], + } + } +} + +const vault_vault = new VaultResource() +export { vault_vault } diff --git a/app/controllers/api/v1/IAM.controller.js b/app/controllers/api/v1/IAM.controller.js index 13537f1..1b79a69 100644 --- a/app/controllers/api/v1/IAM.controller.js +++ b/app/controllers/api/v1/IAM.controller.js @@ -152,12 +152,12 @@ class IAMController extends Controller { if ( !['allow', 'deny'].includes(req.body.access_type) ) return res.status(400) - .message(`${req.T('common.invalid')} access_type. ${req.T('api:must_one')} allow, deny.`) + .message(`${req.T('common.invalid')} access_type. ${req.T('api.must_one')} allow, deny.`) .api() - if ( !['application', 'api_scope', 'machine', 'machine_group'].includes(req.body.target_type) ) + if ( !['application', 'api_scope', 'machine', 'machine_group', 'vault'].includes(req.body.target_type) ) return res.status(400) - .message(`${req.T('common.invalid')} target_type. ${req.T('api:must_one')} application, api_scope, machine, machine_group.`) + .message(`${req.T('common.invalid')} target_type. ${req.T('api.must_one')} application, api_scope, machine, machine_group, vault.`) .api() // Make sure the target_id is valid @@ -188,6 +188,13 @@ class IAMController extends Controller { return res.status(400) .message(`${req.T('common.invalid')} target_id.`) .api() + } else if ( req.body.target_type === 'vault' ) { + const Vault = this.models.get('vault:Vault') + const vault = await Vault.findById(req.body.target_id) + if ( !vault?.active || !(await Policy.check_user_access(req.user, vault.id, 'update')) ) + return res.status(400) + .message(`${req.T('common.invalid')} target_id.`) + .api() } const policy = new Policy({ @@ -230,7 +237,7 @@ class IAMController extends Controller { .api() } - const valid_target_types = ['application', 'api_scope', 'machine', 'machine_group'] + const valid_target_types = ['application', 'api_scope', 'machine', 'machine_group', 'vault'] if ( !valid_target_types.includes(req.body.target_type) ) { return res.status(400) .message(`${req.T('api.invalid_target_type')}`) @@ -312,9 +319,9 @@ class IAMController extends Controller { .message(`${req.T('common.invalid')} access_type. ${req.T('api.must_one')} allow, deny.`) .api() - if ( !['application', 'api_scope', 'machine', 'machine_group'].includes(req.body.target_type) ) + if ( !['application', 'api_scope', 'machine', 'machine_group', 'vault'].includes(req.body.target_type) ) return res.status(400) - .message(`${req.T('common.invalid')} target_type. ${req.T('api.must_one')} application, api_scope, machine, machine_group.`) + .message(`${req.T('common.invalid')} target_type. ${req.T('api.must_one')} application, api_scope, machine, machine_group, vault.`) .api() // Make sure the target_id is valid @@ -345,6 +352,13 @@ class IAMController extends Controller { return res.status(400) .message(`${req.T('common.invalid')} target_id.`) .api() + } else if ( req.body.target_type === 'vault' ) { + const Vault = this.models.get('vault:Vault') + const vault = await Vault.findById(req.body.target_id) + if ( !vault?.active || !(await Policy.check_user_access(req.user, vault.id, 'update')) ) + return res.status(400) + .message(`${req.T('common.invalid')} target_id.`) + .api() } policy.entity_type = req.body.entity_type @@ -389,7 +403,7 @@ class IAMController extends Controller { .api() } - const valid_target_types = ['application', 'api_scope', 'machine', 'machine_group'] + const valid_target_types = ['application', 'api_scope', 'machine', 'machine_group', 'vault'] if ( !valid_target_types.includes(req.body.target_type) ) { return res.status(400) .message(`${req.T('api.invalid_target_type')}`) diff --git a/app/controllers/api/v1/Vault.controller.js b/app/controllers/api/v1/Vault.controller.js new file mode 100644 index 0000000..1977bdd --- /dev/null +++ b/app/controllers/api/v1/Vault.controller.js @@ -0,0 +1,130 @@ +const { Controller } = require('libflitter') + +class VaultController extends Controller { + static get services() { + return [...super.services, 'models'] + } + + async get_vaults(req, res, next) { + const Policy = this.models.get('iam:Policy') + const Vault = this.models.get('vault:Vault') + + await Vault.for_user(req.user) + + const vaults = await Vault.find({ active: true }) + console.log('found vaults', vaults) + + const accessible = [] + for ( const vault of vaults ) { + if ( await Policy.check_user_access(req.user, vault.id, 'view') ) { + accessible.push(await vault.to_api()) + } + } + + return res.api(accessible) + } + + async get_vault(req, res, next) { + const Policy = this.models.get('iam:Policy') + const Vault = this.models.get('vault:Vault') + + const vault = await Vault.findById(req.params.id) + if ( !vault?.active ) { + return res.status(404) + .message(req.T('api.vault_not_found')) + .api() + } + + if ( !(await Policy.check_user_access(req.user, vault.id, 'view')) ) { + return res.status(401) + .message(req.T('api.insufficient_permissions')) + .api() + } + + return res.api(await vault.to_api()) + } + + async create_vault(req, res, next) { + const Policy = this.models.get('iam:Policy') + const Vault = this.models.get('vault:Vault') + + if ( !req.body.name ) { + return res.status(400) + .message(`${req.T('api.missing_field')} name`) + .api() + } + + const vault = new Vault({ + name: req.body.name + }) + + await vault.save() + await vault.grant_default(req.user) + + return res.api(await vault.to_api()) + } + + async update_vault(req, res, next) { + const Policy = this.models.get('iam:Policy') + const Vault = this.models.get('vault:Vault') + + if ( !req.body.name ) { + return res.status(400) + .message(`${req.T('api.missing_field')} name`) + .api() + } + + const vault = await Vault.findById(req.params.id) + if ( !vault?.active ) { + return res.status(404) + .message(req.T('api.vault_not_found')) + .api() + } + + if ( !(await Policy.check_user_access(req.user, vault.id, 'update')) ) { + return res.status(401) + .message(req.T('api.insufficient_permissions')) + .api() + } + + vault.name = req.body.name + await vault.save() + return res.api(await vault.to_api()) + } + + async delete_vault(req, res, next) { + const Policy = this.models.get('iam:Policy') + const Vault = this.models.get('vault:Vault') + + const vault = await Vault.findById(req.params.id) + if ( !vault?.active ) { + return res.status(404) + .message(req.T('api.vault_not_found')) + .api() + } + + if ( !(await Policy.check_user_access(req.user, vault.id, 'delete')) ) { + return res.status(401) + .message(req.T('api.insufficient_permissions')) + .api() + } + + vault.active = false + await vault.save() + + const policies = await Policy.find({ + active: true, + target_type: 'vault', + target_id: vault.id, + }) + + for ( const policy of policies ) { + policy.active = false + await policy.save() + } + + return res.api() + } +} + +module.exports = exports = VaultController diff --git a/app/models/iam/Policy.model.js b/app/models/iam/Policy.model.js index cedc454..59510eb 100644 --- a/app/models/iam/Policy.model.js +++ b/app/models/iam/Policy.model.js @@ -12,7 +12,7 @@ class PolicyModel extends Model { entity_type: String, // user | group entity_id: String, access_type: String, // allow | deny - target_type: { type: String, default: 'application' }, // application | api_scope | machine | machine_group + target_type: { type: String, default: 'application' }, // application | api_scope | machine | machine_group | vault target_id: String, active: { type: Boolean, default: true }, for_permission: { type: Boolean, default: false }, @@ -209,6 +209,10 @@ class PolicyModel extends Model { const MachineGroup = this.models.get('ldap:MachineGroup') const group = await MachineGroup.findById(this.target_id) target_display = `Computer Group: ${group.name} (${group.machine_ids.length} computers)` + } else if ( this.target_type === 'vault' ) { + const Vault = this.models.get('vault:Vault') + const vault = await Vault.findById(this.target_id) + target_display = `Vault: ${vault.name}` } return { diff --git a/app/models/vault/Vault.model.js b/app/models/vault/Vault.model.js new file mode 100644 index 0000000..0dd0e7d --- /dev/null +++ b/app/models/vault/Vault.model.js @@ -0,0 +1,66 @@ +const { Model } = require('flitter-orm') + +class VaultModel extends Model { + static get services() { + return [...super.services, 'models'] + } + + static get schema() { + return { + active: { type: Boolean, default: true }, + name: String, + user_id: String, + } + } + + static async for_user(user) { + const existing = await this.findOne({ + user_id: user.id, + }) + + if ( existing ) return existing + + const vault = new this({ + name: `${user.first_name} ${user.last_name}'s Vault`, + user_id: user.id, + }) + + await vault.save() + await vault.grant_default(user) + + return vault + } + + async grant_default(user) { + const Policy = this.models.get('iam:Policy') + + const grants = ['view', 'read', 'update', 'delete', undefined] + for ( const grant of grants ) { + const policy = new Policy({ + entity_type: 'user', + entity_id: user.id, + access_type: 'allow', + target_type: 'vault', + target_id: this.id, + ...(grant ? { + for_permission: true, + permission: grant + } : {}) + }) + + await policy.save() + } + } + + async to_api() { + return { + id: this.id, + _id: this.id, + name: this.name, + active: this.active, + user_id: this.user_id, + } + } +} + +module.exports = exports = VaultModel diff --git a/app/routing/routers/api/v1/vault.routes.js b/app/routing/routers/api/v1/vault.routes.js new file mode 100644 index 0000000..75a9599 --- /dev/null +++ b/app/routing/routers/api/v1/vault.routes.js @@ -0,0 +1,41 @@ +const iam_routes = { + prefix: '/api/v1/vault', + + middleware: [ + 'auth:APIRoute' + ], + + get: { + '/vaults': [ + ['middleware::api:Permission', { check: 'v1:vault:vaults:list' }], + 'controller::api:v1:Vault.get_vaults', + ], + '/vaults/:id': [ + ['middleware::api:Permission', { check: 'v1:vault:vaults:get' }], + 'controller::api:v1:Vault.get_vault', + ], + }, + + post: { + '/vaults': [ + ['middleware::api:Permission', { check: 'v1:vault:vaults:create' }], + 'controller::api:v1:Vault.create_vault', + ], + }, + + patch: { + '/vaults/:id': [ + ['middleware::api:Permission', { check: 'v1:vault:vaults:update' }], + 'controller::api:v1:Vault.update_vault', + ], + }, + + delete: { + '/vaults/:id': [ + ['middleware::api:Permission', { check: 'v1:vault:vaults:delete' }], + 'controller::api:v1:Vault.delete_vault', + ], + }, +} + +module.exports = exports = iam_routes diff --git a/app/unit/SettingsUnit.js b/app/unit/SettingsUnit.js index f80187b..b6f0680 100644 --- a/app/unit/SettingsUnit.js +++ b/app/unit/SettingsUnit.js @@ -37,6 +37,15 @@ class SettingsUnit extends Unit { this.output.debug(`Guarantee setting key "${key}" with default value "${default_value}".`) await Setting.guarantee(key, default_value) } + + const Permission = this.models.get('iam:Permission') + const default_permissions = this.configs.get('auth.iam.default_permissions') + for ( const perm of default_permissions ) { + const existing = await Permission.findOne(perm) + if ( !existing ) { + await (new Permission(perm)).save() + } + } } } diff --git a/config/auth.config.js b/config/auth.config.js index 34269da..a349e3e 100644 --- a/config/auth.config.js +++ b/config/auth.config.js @@ -3,6 +3,35 @@ const auth_config = { default_provider: env('AUTH_DEFAULT_PROVIDER', 'flitter'), default_login_route: '/dash', + iam: { + default_permissions: [ + { + target_type: 'machine', + permission: 'sudo', + }, + { + target_type: 'machine_group', + permission: 'sudo', + }, + { + target_type: 'vault', + permission: 'view', + }, + { + target_type: 'vault', + permission: 'read', + }, + { + target_type: 'vault', + permission: 'update', + }, + { + target_type: 'vault', + permission: 'delete', + }, + ], + }, + mfa: { secret_length: env('MFA_SECRET_LENGTH', 20) }, diff --git a/locale/en_US/api.locale.js b/locale/en_US/api.locale.js index 50a16ef..25ada0c 100644 --- a/locale/en_US/api.locale.js +++ b/locale/en_US/api.locale.js @@ -6,6 +6,7 @@ module.exports = exports = { machine_not_found: 'Machine not found with that ID.', group_already_exists: 'A group with that name already exists.', machine_already_exists: 'A machine with that name already exists.', + vault_not_found: 'A vault with that ID not found.', user_not_found: 'User not found with that ID.', user_already_exists: 'A user with that identifier already exists.',