Implement OAuth2 server, link oauth:Client and auth::Oauth2Client, implement permission checks
This commit is contained in:
parent
6f621f5891
commit
d558f21375
@ -40,7 +40,6 @@ export default class MFADisableComponent extends Component {
|
|||||||
|
|
||||||
vue_on_create() {
|
vue_on_create() {
|
||||||
this.app_name = session.get('app.name')
|
this.app_name = session.get('app.name')
|
||||||
console.log({session})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async back_click() {
|
async back_click() {
|
||||||
|
@ -5,52 +5,111 @@ import { resource_service } from '../service/Resource.service.js'
|
|||||||
import { action_service } from '../service/Action.service.js'
|
import { action_service } from '../service/Action.service.js'
|
||||||
|
|
||||||
const template = `
|
const template = `
|
||||||
<div class="card col-12 col-sm-10 offset-sm-1 col-md-8 offset-md-2 col-xl-6 offset-xl-3">
|
<div class="card col-12 col-sm-10 offset-sm-1 col-md-8 offset-md-2 col-xl-6 offset-xl-3 mb-5">
|
||||||
<h3 class="card-title mb-4 mt-4">
|
<span v-if="!can_access">
|
||||||
<button class="btn-lg btn" @click="back"><i class="fa fa-arrow-left"></i></button> {{ title }}
|
<div class="row m-5">
|
||||||
</h3>
|
<div class="col-12 text-center">
|
||||||
<form v-on:submit.prevent="do_nothing" v-if="is_ready">
|
<h5>{{ access_msg }}</h5>
|
||||||
<div class="form-group" v-for="field of definition.fields">
|
</div>
|
||||||
<span v-if="field.type.startsWith('select')">
|
|
||||||
<label :for="uuid+field.field">{{ field.name }}</label>
|
|
||||||
<select
|
|
||||||
:id="uuid+field.field"
|
|
||||||
class="form-control"
|
|
||||||
v-model="data[field.field]"
|
|
||||||
:required="field.required"
|
|
||||||
:readonly="mode === 'view'"
|
|
||||||
:multiple="!!field.type.endsWith('.multiple')"
|
|
||||||
>
|
|
||||||
<option v-for="option of field.options" :value="option.value">{{ typeof option.display === 'function' ? option.display(option) : option.display }}</option>
|
|
||||||
</select>
|
|
||||||
<small class="form-text" style="color: darkred;" v-if="field.error">{{ field.error }}</small>
|
|
||||||
</span>
|
|
||||||
<span v-if="field.type === 'text'">
|
|
||||||
<label :for="uuid+field.field">{{ field.name }}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
:id="uuid+field.field"
|
|
||||||
v-model="data[field.field]"
|
|
||||||
:required="field.required"
|
|
||||||
:placeholder="field.placeholder"
|
|
||||||
:readonly="mode === 'view'"
|
|
||||||
ref="input"
|
|
||||||
>
|
|
||||||
<small class="form-text" style="color: darkred;" v-if="field.error">{{ field.error }}</small>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</span>
|
||||||
<div class="col-12 text-right mb-4 mr-0 mt-2">
|
<span v-if="can_access">
|
||||||
<span style="color: darkred;" class="font-italic mr-3" v-if="error_message">{{ error_message }}</span>
|
<h3 class="card-title mb-4 mt-4">
|
||||||
<span class="font-italic mr-3 text-muted" v-if="other_message">{{ other_message }}</span>
|
<button class="btn-lg btn" @click="back"><i class="fa fa-arrow-left"></i></button> {{ title }}
|
||||||
<button
|
</h3>
|
||||||
class="btn btn-primary"
|
<div class="row" v-if="!is_ready">
|
||||||
type="button"
|
<div class="col-12 text-center pad-top mb-5">
|
||||||
v-if="mode !== 'view'"
|
<h4>Loading...</h4>
|
||||||
@click="save_click"
|
</div>
|
||||||
>Save</button>
|
</div>
|
||||||
</div>
|
<form v-on:submit.prevent="do_nothing" v-if="is_ready">
|
||||||
|
<div class="form-group" v-for="field of definition.fields">
|
||||||
|
<span
|
||||||
|
v-if="field.type === 'display' && (Array.isArray(field.hidden) ? !field.hidden.includes(mode) : !field.hidden) && (typeof field.if !== 'function' || field.if(data))"
|
||||||
|
v-html="typeof field.display === 'function' ? field.display(data) : field.display"
|
||||||
|
></span>
|
||||||
|
<span v-if="field.type.startsWith('select') && (Array.isArray(field.hidden) ? !field.hidden.includes(mode) : !field.hidden) && (typeof field.if !== 'function' || field.if(data))">
|
||||||
|
<label :for="uuid+field.field">{{ field.name }}</label>
|
||||||
|
<select
|
||||||
|
:id="uuid+field.field"
|
||||||
|
class="form-control"
|
||||||
|
v-model="data[field.field]"
|
||||||
|
:required="Array.isArray(field.required) ? field.required.includes(mode) : field.required"
|
||||||
|
:readonly="mode === 'view' || (Array.isArray(field.readonly) ? field.readonly.includes(mode) : field.readonly)"
|
||||||
|
:multiple="!!field.type.endsWith('.multiple')"
|
||||||
|
ref="input"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="option of field.options"
|
||||||
|
:value="option.value"
|
||||||
|
:selected="data[field.field] && data[field.field].includes(option.value)"
|
||||||
|
>{{ typeof option.display === 'function' ? option.display(option) : option.display }}</option>
|
||||||
|
</select>
|
||||||
|
<small class="form-text" style="color: darkred;" v-if="field.error">{{ field.error }}</small>
|
||||||
|
</span>
|
||||||
|
<span v-if="field.type === 'text' && (Array.isArray(field.hidden) ? !field.hidden.includes(mode) : !field.hidden) && (typeof field.if !== 'function' || field.if(data))">
|
||||||
|
<label :for="uuid+field.field">{{ field.name }}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
:id="uuid+field.field"
|
||||||
|
v-model="data[field.field]"
|
||||||
|
:required="Array.isArray(field.required) ? field.required.includes(mode) : field.required"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
:readonly="mode === 'view' || (Array.isArray(field.readonly) ? field.readonly.includes(mode) : field.readonly)"
|
||||||
|
ref="input"
|
||||||
|
>
|
||||||
|
<small class="form-text" style="color: darkred;" v-if="field.error">{{ field.error }}</small>
|
||||||
|
</span>
|
||||||
|
<span v-if="field.type === 'textarea' && (Array.isArray(field.hidden) ? !field.hidden.includes(mode) : !field.hidden) && (typeof field.if !== 'function' || field.if(data))">
|
||||||
|
<label :for="uuid+field.field">{{ field.name }}</label>
|
||||||
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
:id="uuid+field.field"
|
||||||
|
v-model="data[field.field]"
|
||||||
|
:required="Array.isArray(field.required) ? field.required.includes(mode) : field.required"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
:readonly="mode === 'view' || (Array.isArray(field.readonly) ? field.readonly.includes(mode) : field.readonly)"
|
||||||
|
ref="input"
|
||||||
|
></textarea>
|
||||||
|
<small class="form-text" style="color: darkred;" v-if="field.error">{{ field.error }}</small>
|
||||||
|
</span>
|
||||||
|
<span v-if="field.type === 'password' && (Array.isArray(field.hidden) ? !field.hidden.includes(mode) : !field.hidden) && (typeof field.if !== 'function' || field.if(data))">
|
||||||
|
<label :for="uuid+field.field">{{ field.name }}</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
:id="uuid+field.field"
|
||||||
|
v-model="data[field.field]"
|
||||||
|
:required="Array.isArray(field.required) ? field.required.includes(mode) : field.required"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
:readonly="mode === 'view' || (Array.isArray(field.readonly) ? field.readonly.includes(mode) : field.readonly)"
|
||||||
|
ref="input"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
:id="uuid+field.field+'-confirm'"
|
||||||
|
v-model="data[field.field+'-confirm']"
|
||||||
|
:required="Array.isArray(field.required) ? field.required.includes(mode) : field.required"
|
||||||
|
:placeholder="'Confirm ' + field.name"
|
||||||
|
:readonly="mode === 'view' || (Array.isArray(field.readonly) ? field.readonly.includes(mode) : field.readonly)"
|
||||||
|
>
|
||||||
|
<small class="form-text" style="color: darkred;" v-if="field.error">{{ field.error }}</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="col-12 text-right mb-4 mr-0 mt-2">
|
||||||
|
<span style="color: darkred;" class="font-italic mr-3" v-if="error_message">{{ error_message }}</span>
|
||||||
|
<span class="font-italic mr-3 text-muted" v-if="other_message">{{ other_message }}</span>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
type="button"
|
||||||
|
v-if="mode !== 'view'"
|
||||||
|
@click="save_click"
|
||||||
|
>Save</button>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|
||||||
@ -80,6 +139,9 @@ export default class FormComponent extends Component {
|
|||||||
error_message = ''
|
error_message = ''
|
||||||
other_message = ''
|
other_message = ''
|
||||||
|
|
||||||
|
access_msg = ''
|
||||||
|
can_access = false
|
||||||
|
|
||||||
is_ready = false
|
is_ready = false
|
||||||
mode = ''
|
mode = ''
|
||||||
id = ''
|
id = ''
|
||||||
@ -99,49 +161,62 @@ export default class FormComponent extends Component {
|
|||||||
this.mode = this.initial_mode
|
this.mode = this.initial_mode
|
||||||
this.id = this.form_id
|
this.id = this.form_id
|
||||||
this.resource_class = await resource_service.get(this.resource)
|
this.resource_class = await resource_service.get(this.resource)
|
||||||
|
|
||||||
|
if ( await this.resource_class.can(this.mode) ) {
|
||||||
|
this.can_access = true
|
||||||
|
this.access_msg = true
|
||||||
|
} else {
|
||||||
|
this.can_access = false
|
||||||
|
this.access_msg = 'Sorry, you do not have permission to ' + this.mode + ' this resource.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
this.reset()
|
this.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
this.uuid = utility.uuid()
|
this.uuid = utility.uuid()
|
||||||
await this.load()
|
|
||||||
await this.init()
|
await this.init()
|
||||||
|
await this.load()
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
|
this.definition = this.resource_class.form_definition
|
||||||
for ( const field of this.definition.fields ) {
|
for ( const field of this.definition.fields ) {
|
||||||
if ( field.type.startsWith('select.dynamic') ) {
|
if ( field.type.startsWith('select.dynamic') ) {
|
||||||
field._options = field._options || field.options
|
field._options = field._options || field.options
|
||||||
const rsc = await resource_service.get(field._options.resource)
|
const rsc = await resource_service.get(field._options.resource)
|
||||||
|
const other_params = field._options.other_params || {}
|
||||||
|
|
||||||
field.options = (await rsc.list()).map(item => {
|
field.options = (await rsc.list(other_params)).map(item => {
|
||||||
return {
|
return {
|
||||||
display: typeof field._options.display === 'function' ? field._options.display(item) : item[field._options.display || 'display'],
|
display: typeof field._options.display === 'function' ? field._options.display(item) : item[field._options.display || 'display'],
|
||||||
value: typeof field._options.value === 'function' ? field._options.value(item) : item[field._options.value || 'display'],
|
value: typeof field._options.value === 'function' ? field._options.value(item) : item[field._options.value || 'display'],
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ( field.type.endsWith('.multiple') ) {
|
async load() {
|
||||||
|
if (this.mode !== 'insert') {
|
||||||
|
this.data = await this.resource_class.get(this.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for ( const field of this.definition.fields ) {
|
||||||
|
if ( field.type.endsWith('.multiple') && !this.data[field.field] ) {
|
||||||
this.data[field.field] = []
|
this.data[field.field] = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.title = title_map[this.mode] + ' ' + this.resource_class.item
|
||||||
|
|
||||||
this.is_ready = true
|
this.is_ready = true
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
if ( this.mode !== 'view' ) this.$refs.input[0].focus()
|
if ( this.mode !== 'view' ) this.$refs.input[0].focus()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async load() {
|
|
||||||
this.definition = this.resource_class.form_definition
|
|
||||||
if (this.mode !== 'insert') {
|
|
||||||
this.data = await this.resource_class.get(this.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.title = title_map[this.mode] + ' ' + this.resource_class.item
|
|
||||||
}
|
|
||||||
|
|
||||||
async on_create() {
|
async on_create() {
|
||||||
this.id = this.data.id
|
this.id = this.data.id
|
||||||
this.mode = 'update'
|
this.mode = 'update'
|
||||||
@ -171,9 +246,12 @@ export default class FormComponent extends Component {
|
|||||||
validate() {
|
validate() {
|
||||||
let valid = true
|
let valid = true
|
||||||
for ( const field of this.definition.fields ) {
|
for ( const field of this.definition.fields ) {
|
||||||
if ( field.required && (!(field.field in this.data) || !this.data[field.field]) ) {
|
if ( (Array.isArray(field.required) ? field.required.includes(this.mode) : field.required) && (!(field.field in this.data) || !this.data[field.field]) ) {
|
||||||
field.error = 'This field is required.'
|
field.error = 'This field is required.'
|
||||||
valid = false
|
valid = false
|
||||||
|
} else if ( field.type === 'password' && this.data[field.field] !== this.data[field.field + '-confirm'] ) {
|
||||||
|
field.error = field.name + ' confirmation does not match.'
|
||||||
|
valid = false
|
||||||
} else {
|
} else {
|
||||||
field.error = ''
|
field.error = ''
|
||||||
}
|
}
|
||||||
|
@ -1,49 +1,62 @@
|
|||||||
import { Component } from '../../lib/vues6/vues6.js'
|
import { Component } from '../../lib/vues6/vues6.js'
|
||||||
import { action_service } from '../service/Action.service.js'
|
import { action_service } from '../service/Action.service.js'
|
||||||
import { message_service } from '../service/Message.service.js'
|
import { message_service } from '../service/Message.service.js'
|
||||||
|
import { resource_service } from '../service/Resource.service.js'
|
||||||
|
|
||||||
const template = `
|
const template = `
|
||||||
<div>
|
<div>
|
||||||
<div class="row mb-4">
|
<span v-if="!can_access">
|
||||||
<div class="col-10"><h3>{{ resource_class.plural }}</h3></div>
|
<div class="row m-5">
|
||||||
<div class="col-2 text-right" v-if="definition.actions">
|
<div class="col-12 text-center">
|
||||||
<button
|
<h4 class="pad-top">{{ access_msg }}</h4>
|
||||||
:class="['btn', 'btn-'+(action.color || 'secondary'), 'btn-sm']"
|
</div>
|
||||||
type="button"
|
|
||||||
v-for="action of definition.actions"
|
|
||||||
@click="perform($event, action)"
|
|
||||||
v-if="action.position === 'main'"
|
|
||||||
>{{ action.text }}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</span>
|
||||||
<table class="table">
|
<span v-if="can_access">
|
||||||
<thead>
|
<div class="row mb-4">
|
||||||
<tr>
|
<div class="col-10"><h3>{{ resource_class.plural }}</h3></div>
|
||||||
<th scope="col">#</th>
|
<div class="col-2 text-right" v-if="definition.actions">
|
||||||
<th scope="col" v-for="col of definition.columns">{{ col.name }}</th>
|
<button
|
||||||
<th scope="col"></th>
|
:class="['btn', 'btn-'+(action.color || 'secondary'), 'btn-sm']"
|
||||||
</tr>
|
type="button"
|
||||||
</thead>
|
v-for="action of definition.actions"
|
||||||
<tbody>
|
@click="perform($event, action)"
|
||||||
<tr v-for="(row, index) of data">
|
v-if="action.position === 'main'"
|
||||||
<th scope="row">{{ index + 1 }}</th>
|
>{{ action.text }}</button>
|
||||||
<td v-for="col of definition.columns">
|
</div>
|
||||||
<span v-if="typeof col.renderer === 'function'">{{ col.renderer(row[col.field]) }}</span>
|
</div>
|
||||||
<span v-if="col.renderer === 'boolean'">{{ row[col.field] ? 'Yes' : 'No' }}</span>
|
<div class="row mb-4" v-if="definition.display">
|
||||||
<span v-if="col.renderer !== 'boolean' && typeof col.renderer !== 'function'">{{ col.field in row ? row[col.field] : '-' }}</span>
|
<div class="col-12" v-html="definition.display"></div>
|
||||||
</td>
|
</div>
|
||||||
<td>
|
<table class="table">
|
||||||
<button
|
<thead>
|
||||||
type="button"
|
<tr>
|
||||||
:class="['mr-2', 'btn', 'btn-sm', 'btn-'+(action.color || 'secondary')]"
|
<th scope="col">#</th>
|
||||||
v-for="action of definition.actions"
|
<th scope="col" v-for="col of definition.columns">{{ col.name }}</th>
|
||||||
v-if="action.position === 'row'"
|
<th scope="col"></th>
|
||||||
@click="perform($event, action, row)"
|
</tr>
|
||||||
><i :class="action.icon" v-if="action.icon"></i> {{ action.text }}</button>
|
</thead>
|
||||||
</td>
|
<tbody>
|
||||||
</tr>
|
<tr v-for="(row, index) of data">
|
||||||
</tbody>
|
<th scope="row">{{ index + 1 }}</th>
|
||||||
</table>
|
<td v-for="col of definition.columns">
|
||||||
|
<span v-if="typeof col.renderer === 'function'">{{ col.renderer(row[col.field]) }}</span>
|
||||||
|
<span v-if="col.renderer === 'boolean'">{{ row[col.field] ? 'Yes' : 'No' }}</span>
|
||||||
|
<span v-if="col.renderer !== 'boolean' && typeof col.renderer !== 'function'">{{ col.field in row ? row[col.field] : '-' }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="['mr-2', 'btn', 'btn-sm', 'btn-'+(action.color || 'secondary')]"
|
||||||
|
v-for="action of definition.actions"
|
||||||
|
v-if="action.position === 'row'"
|
||||||
|
@click="perform($event, action, row)"
|
||||||
|
><i :class="action.icon" v-if="action.icon"></i> {{ action.text }}</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|
||||||
@ -56,17 +69,23 @@ export default class ListingComponent extends Component {
|
|||||||
data = []
|
data = []
|
||||||
resource_class = {}
|
resource_class = {}
|
||||||
|
|
||||||
|
access_msg = ''
|
||||||
|
can_access = false
|
||||||
|
|
||||||
async vue_on_create() {
|
async vue_on_create() {
|
||||||
// Load the resource
|
// Load the resource
|
||||||
const resource_mod = await import(`../resource/${this.resource}.resource.js`)
|
this.resource_class = await resource_service.get(this.resource)
|
||||||
if ( !resource_mod )
|
|
||||||
throw new Error('Unable to load Cobalt listing resource.')
|
|
||||||
|
|
||||||
const rsc_name = this.resource.toLowerCase().replace(/\//g, '_')
|
// Make sure we have permission
|
||||||
if ( !resource_mod[rsc_name] )
|
if ( !(await this.resource_class.can('list')) ) {
|
||||||
throw new Error('Unable to extract resource object from module.')
|
this.access_msg = 'Sorry, you do not have permission to view this resource.'
|
||||||
|
this.can_access = false
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
this.access_msg = ''
|
||||||
|
this.can_access = true
|
||||||
|
}
|
||||||
|
|
||||||
this.resource_class = resource_mod[rsc_name]
|
|
||||||
await this.load()
|
await this.load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -37,6 +37,7 @@ const template = `
|
|||||||
>
|
>
|
||||||
<h6 class="dropdown-header">Hello, {{ first_name }}.</h6>
|
<h6 class="dropdown-header">Hello, {{ first_name }}.</h6>
|
||||||
<a href="/dash/profile" class="dropdown-item">My Profile</a>
|
<a href="/dash/profile" class="dropdown-item">My Profile</a>
|
||||||
|
<a href="/dash/c/listing/reflect/Token" v-if="can.api_tokens" class="dropdown-item">API Tokens</a>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<a href="/auth/logout" class="dropdown-item">Sign-Out of {{ app_name }}</a>
|
<a href="/auth/logout" class="dropdown-item">Sign-Out of {{ app_name }}</a>
|
||||||
</div>
|
</div>
|
||||||
@ -51,6 +52,8 @@ export default class NavBarComponent extends Component {
|
|||||||
static get template() { return template }
|
static get template() { return template }
|
||||||
static get props() { return [] }
|
static get props() { return [] }
|
||||||
|
|
||||||
|
can = {}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.toggle_event = event_bus.event('sidebar.toggle')
|
this.toggle_event = event_bus.event('sidebar.toggle')
|
||||||
@ -59,6 +62,10 @@ export default class NavBarComponent extends Component {
|
|||||||
this.app_name = session.get('app.name')
|
this.app_name = session.get('app.name')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async vue_on_create() {
|
||||||
|
this.can.api_tokens = await session.check_permissions('v1:reflect:tokens:list')
|
||||||
|
}
|
||||||
|
|
||||||
toggle_sidebar() {
|
toggle_sidebar() {
|
||||||
this.toggle_event.fire()
|
this.toggle_event.fire()
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { Component } from '../../lib/vues6/vues6.js'
|
import { Component } from '../../lib/vues6/vues6.js'
|
||||||
import { event_bus } from '../service/EventBus.service.js'
|
import { event_bus } from '../service/EventBus.service.js'
|
||||||
import { action_service } from '../service/Action.service.js'
|
import { action_service } from '../service/Action.service.js'
|
||||||
|
import { resource_service } from '../service/Resource.service.js'
|
||||||
|
import { session } from '../service/Session.service.js'
|
||||||
|
|
||||||
const template = `
|
const template = `
|
||||||
<div class="bg-light border-right coreid-sidebar-wrapper" id="sidebar-wrapper" v-bind:class="{ collapsed: isCollapsed }">
|
<div class="bg-light border-right coreid-sidebar-wrapper" id="sidebar-wrapper" v-bind:class="{ collapsed: isCollapsed }">
|
||||||
@ -23,7 +25,9 @@ export default class SideBarComponent extends Component {
|
|||||||
static get props() { return ['app_name'] }
|
static get props() { return ['app_name'] }
|
||||||
static get template() { return template }
|
static get template() { return template }
|
||||||
|
|
||||||
actions = [
|
actions = []
|
||||||
|
|
||||||
|
possible_actions = [
|
||||||
{
|
{
|
||||||
text: 'Profile',
|
text: 'Profile',
|
||||||
action: 'redirect',
|
action: 'redirect',
|
||||||
@ -31,23 +35,45 @@ export default class SideBarComponent extends Component {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Users',
|
text: 'Users',
|
||||||
action: 'redirect',
|
action: 'list',
|
||||||
next: '/dash/users',
|
type: 'resource',
|
||||||
|
resource: 'auth/User',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Groups',
|
text: 'Groups',
|
||||||
action: 'redirect',
|
action: 'list',
|
||||||
next: '/dash/c/listing/ldap/Group',
|
type: 'resource',
|
||||||
|
resource: 'auth/Group',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Applications',
|
||||||
|
action: 'list',
|
||||||
|
type: 'resource',
|
||||||
|
resource: 'App',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'IAM Policy',
|
||||||
|
action: 'list',
|
||||||
|
type: 'resource',
|
||||||
|
resource: 'iam/Policy',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'LDAP Clients',
|
text: 'LDAP Clients',
|
||||||
action: 'redirect',
|
action: 'list',
|
||||||
next: '/dash/c/listing/ldap/Client',
|
type: 'resource',
|
||||||
|
resource: 'ldap/Client',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'OAuth2 Clients',
|
||||||
|
action: 'list',
|
||||||
|
type: 'resource',
|
||||||
|
resource: 'oauth/Client',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'SAML Service Providers',
|
text: 'SAML Service Providers',
|
||||||
action: 'redirect',
|
action: 'list',
|
||||||
next: '/dash/c/listing/saml/Provider',
|
type: 'resource',
|
||||||
|
resource: 'saml/Provider',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Settings',
|
text: 'Settings',
|
||||||
@ -63,6 +89,32 @@ export default class SideBarComponent extends Component {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async vue_on_create() {
|
||||||
|
const new_actions = []
|
||||||
|
|
||||||
|
const perm_lookups = []
|
||||||
|
for ( const action of this.possible_actions ) {
|
||||||
|
if ( action.resource ) {
|
||||||
|
action.rsc = await resource_service.get(action.resource)
|
||||||
|
perm_lookups.push(`${action.rsc.permission_base}:list`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const perms = await session.check_permissions(...perm_lookups)
|
||||||
|
|
||||||
|
for ( const action of this.possible_actions ) {
|
||||||
|
if ( action.resource ) {
|
||||||
|
if ( perms[`${action.rsc.permission_base}:list`] ) {
|
||||||
|
new_actions.push(action)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
new_actions.push(action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.actions = new_actions
|
||||||
|
}
|
||||||
|
|
||||||
isCollapsed = false
|
isCollapsed = false
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
|
@ -175,8 +175,6 @@ export default class EditProfileComponent extends Component {
|
|||||||
this.form_message = 'Saving...'
|
this.form_message = 'Saving...'
|
||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('profile form', this)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get_submit_data() {
|
get_submit_data() {
|
||||||
|
@ -80,7 +80,6 @@ export default class AppPasswordFormComponent extends Component {
|
|||||||
|
|
||||||
vue_on_create() {
|
vue_on_create() {
|
||||||
this.uuid = utility.uuid()
|
this.uuid = utility.uuid()
|
||||||
console.log({auth_api})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async on_name_change(event) {
|
async on_name_change(event) {
|
||||||
|
112
app/assets/app/resource/App.resource.js
Normal file
112
app/assets/app/resource/App.resource.js
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import CRUDBase from './CRUDBase.js'
|
||||||
|
import { session } from '../service/Session.service.js'
|
||||||
|
|
||||||
|
class AppResource extends CRUDBase {
|
||||||
|
endpoint = '/api/v1/applications'
|
||||||
|
required_fields = ['name', 'identifier']
|
||||||
|
permission_base = 'v1:applications'
|
||||||
|
|
||||||
|
item = 'Application'
|
||||||
|
plural = 'Applications'
|
||||||
|
|
||||||
|
listing_definition = {
|
||||||
|
display: `
|
||||||
|
An application is anything that can authenticate users against ${session.get('app.name')}. Applications can have any number of associated LDAP clients, SAML service providers, and OAuth2 clients.
|
||||||
|
`,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'Name',
|
||||||
|
field: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Identifier',
|
||||||
|
field: 'identifier',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Description',
|
||||||
|
field: 'description',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
form_definition = {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'Name',
|
||||||
|
field: 'name',
|
||||||
|
placeholder: 'Awesome App',
|
||||||
|
required: true,
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Identifier',
|
||||||
|
field: 'identifier',
|
||||||
|
placeholder: 'awesome_app',
|
||||||
|
required: true,
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Description',
|
||||||
|
field: 'description',
|
||||||
|
type: 'textarea',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Associated LDAP Clients',
|
||||||
|
field: 'ldap_client_ids',
|
||||||
|
type: 'select.dynamic.multiple',
|
||||||
|
options: {
|
||||||
|
resource: 'ldap/Client',
|
||||||
|
display: 'name',
|
||||||
|
value: 'id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Associated OAuth2 Clients',
|
||||||
|
field: 'oauth_client_ids',
|
||||||
|
type: 'select.dynamic.multiple',
|
||||||
|
options: {
|
||||||
|
resource: 'oauth/Client',
|
||||||
|
display: 'name',
|
||||||
|
value: 'id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Associated SAML Service Providers',
|
||||||
|
field: 'saml_service_provider_ids',
|
||||||
|
type: 'select.dynamic.multiple',
|
||||||
|
options: {
|
||||||
|
resource: 'saml/Provider',
|
||||||
|
display: 'name',
|
||||||
|
value: 'id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = new AppResource()
|
||||||
|
export { app }
|
@ -1,8 +1,10 @@
|
|||||||
import APIParseError from './APIParseError.js'
|
import APIParseError from './APIParseError.js'
|
||||||
|
import { session } from '../service/Session.service.js'
|
||||||
|
|
||||||
export default class CRUDBase {
|
export default class CRUDBase {
|
||||||
endpoint = '/api/v1'
|
endpoint = '/api/v1'
|
||||||
required_fields = []
|
required_fields = []
|
||||||
|
permission_base = ''
|
||||||
|
|
||||||
listing_definition = {}
|
listing_definition = {}
|
||||||
form_definition = {}
|
form_definition = {}
|
||||||
@ -10,14 +12,18 @@ export default class CRUDBase {
|
|||||||
item = ''
|
item = ''
|
||||||
plural = ''
|
plural = ''
|
||||||
|
|
||||||
async list() {
|
async can(action) {
|
||||||
const results = await axios.get(this._endpoint())
|
return session.check_permissions(`${this.permission_base}:${action}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(other_params = {}) {
|
||||||
|
const results = await axios.get(this._endpoint(), { params: other_params })
|
||||||
if ( results && results.data && Array.isArray(results.data.data) ) return results.data.data
|
if ( results && results.data && Array.isArray(results.data.data) ) return results.data.data
|
||||||
else throw new APIParseError()
|
else throw new APIParseError()
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(id) {
|
async get(id, other_params = {}) {
|
||||||
const results = await axios.get(this._endpoint(id))
|
const results = await axios.get(this._endpoint(id), { params: other_params })
|
||||||
if ( results && results.data && results.data.data ) return results.data.data
|
if ( results && results.data && results.data.data ) return results.data.data
|
||||||
else throw new APIParseError()
|
else throw new APIParseError()
|
||||||
}
|
}
|
||||||
@ -40,8 +46,8 @@ export default class CRUDBase {
|
|||||||
await axios.patch(this._endpoint(id), properties)
|
await axios.patch(this._endpoint(id), properties)
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(id) {
|
async delete(id, other_params = {}) {
|
||||||
await axios.delete(this._endpoint(id))
|
await axios.delete(this._endpoint(id), { params: other_params })
|
||||||
}
|
}
|
||||||
|
|
||||||
_endpoint(sub = '/') {
|
_endpoint(sub = '/') {
|
||||||
|
77
app/assets/app/resource/auth/Group.resource.js
Normal file
77
app/assets/app/resource/auth/Group.resource.js
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import CRUDBase from '../CRUDBase.js'
|
||||||
|
import { session } from '../../service/Session.service.js'
|
||||||
|
|
||||||
|
class GroupResource extends CRUDBase {
|
||||||
|
endpoint = '/api/v1/auth/groups'
|
||||||
|
required_fields = ['name']
|
||||||
|
permission_base = 'v1:auth:groups'
|
||||||
|
|
||||||
|
item = 'Group'
|
||||||
|
plural = 'Groups'
|
||||||
|
|
||||||
|
listing_definition = {
|
||||||
|
display: `
|
||||||
|
In ${session.get('app.name')}, groups are simply a tool for organizing users and assigning permissions and access in bulk. After creating and assigning users to a group, you can manage permissions for that group, and its policies will be applied to all users in that group.
|
||||||
|
`,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'Name',
|
||||||
|
field: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '# of Users',
|
||||||
|
field: 'user_ids',
|
||||||
|
renderer: (user_ids) => Array.isArray(user_ids) ? user_ids.length : 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
form_definition = {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'Name',
|
||||||
|
field: 'name',
|
||||||
|
placeholder: 'Some Cool Users',
|
||||||
|
required: true,
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Users',
|
||||||
|
field: 'user_ids',
|
||||||
|
type: 'select.dynamic.multiple',
|
||||||
|
options: {
|
||||||
|
resource: 'auth/User',
|
||||||
|
display: (user) => `${user.last_name}, ${user.first_name} (${user.uid})`,
|
||||||
|
value: 'id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth_group = new GroupResource()
|
||||||
|
export { auth_group }
|
@ -3,6 +3,7 @@ import CRUDBase from '../CRUDBase.js'
|
|||||||
class RoleResource extends CRUDBase {
|
class RoleResource extends CRUDBase {
|
||||||
endpoint = '/api/v1/auth/roles'
|
endpoint = '/api/v1/auth/roles'
|
||||||
required_fields = ['role', 'permissions']
|
required_fields = ['role', 'permissions']
|
||||||
|
permission_base = 'v1:auth:roles'
|
||||||
|
|
||||||
item = 'Role'
|
item = 'Role'
|
||||||
plural = 'Roles'
|
plural = 'Roles'
|
||||||
|
@ -1,11 +1,106 @@
|
|||||||
import CRUDBase from '../CRUDBase.js'
|
import CRUDBase from '../CRUDBase.js'
|
||||||
|
import { session } from '../../service/Session.service.js'
|
||||||
|
|
||||||
class UserResource extends CRUDBase {
|
class UserResource extends CRUDBase {
|
||||||
endpoint = '/api/v1/auth/users'
|
endpoint = '/api/v1/auth/users'
|
||||||
required_fields = ['uid', 'first_name', 'last_name', 'email', 'password']
|
required_fields = ['uid', 'first_name', 'last_name', 'email']
|
||||||
|
permission_base = 'v1:auth:users'
|
||||||
|
|
||||||
item = 'User'
|
item = 'User'
|
||||||
plural = 'Users'
|
plural = 'Users'
|
||||||
|
|
||||||
|
listing_definition = {
|
||||||
|
display: `
|
||||||
|
Users can be assigned permissions and, if granted, can manage their ${session.get('app.name')} accounts from the Profile page, as well as login to the external applications they've been given access to.
|
||||||
|
`,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'UID',
|
||||||
|
field: 'uid',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Last Name',
|
||||||
|
field: 'last_name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'First Name',
|
||||||
|
field: 'first_name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E-Mail',
|
||||||
|
field: 'email',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
form_definition = {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'First Name',
|
||||||
|
field: 'first_name',
|
||||||
|
placeholder: 'John',
|
||||||
|
required: true,
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Last Name',
|
||||||
|
field: 'last_name',
|
||||||
|
placeholder: 'Doe',
|
||||||
|
required: true,
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Username',
|
||||||
|
field: 'uid',
|
||||||
|
placeholder: 'john.doe',
|
||||||
|
required: true,
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'E-Mail',
|
||||||
|
field: 'email',
|
||||||
|
placeholder: 'john@contoso.com',
|
||||||
|
required: true,
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Tagline',
|
||||||
|
field: 'tagline',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Password',
|
||||||
|
field: 'password',
|
||||||
|
type: 'password',
|
||||||
|
placeholder: 'Password',
|
||||||
|
required: ['insert'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const auth_user = new UserResource()
|
const auth_user = new UserResource()
|
||||||
|
140
app/assets/app/resource/iam/Policy.resource.js
Normal file
140
app/assets/app/resource/iam/Policy.resource.js
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import CRUDBase from '../CRUDBase.js'
|
||||||
|
import { session } from '../../service/Session.service.js'
|
||||||
|
|
||||||
|
class PolicyResource extends CRUDBase {
|
||||||
|
endpoint = '/api/v1/iam/policy'
|
||||||
|
required_fields = ['entity_id', 'entity_type', 'target_id', 'target_type', 'access_type']
|
||||||
|
permission_base = 'v1:iam:policy'
|
||||||
|
|
||||||
|
item = 'IAM Policy'
|
||||||
|
plural = 'IAM Policies'
|
||||||
|
|
||||||
|
listing_definition = {
|
||||||
|
display: `
|
||||||
|
Identity & Access Management (IAM) policies give you fine grained control over which ${session.get('app.name')} users and groups are allowed to access which applications.
|
||||||
|
<br><br>
|
||||||
|
An IAM policy has three parts. First, is the subject. The subject is who the policy applies to and is either a user or a group. The second part is the access type. This is either an allowance or a denial. That is, the policy either grants a subject access to a resource, or explicitly denies them access. The final part of the policy is the target. This is the application that the subject is being granted or denied access to.
|
||||||
|
<br><br>
|
||||||
|
Note that IAM policies can be overlapping. So, ${session.get('app.name')}'s policy engine follows a few basic rules when deciding what policies take precedence:
|
||||||
|
<br><br>
|
||||||
|
<ol>
|
||||||
|
<li>User policy takes precedence over group policy.</li>
|
||||||
|
<li>Denials take precedence over approvals.</li>
|
||||||
|
<li>Denials by default.</li>
|
||||||
|
</ol>
|
||||||
|
This means, for example, that if a user's group is allowed access, but a user is denied access, the user will be denied access. Likewise, if there are two policies for a subject, one granting them access and one denying them access, the denial will take precedence.
|
||||||
|
`,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'Subject',
|
||||||
|
field: 'entity_display',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Access Type',
|
||||||
|
field: 'access_type',
|
||||||
|
renderer: access_type => access_type === 'deny' ? '...is denied access to...' : '...is granted access to...',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Target',
|
||||||
|
field: 'target_display',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
form_definition = {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'Subject Type',
|
||||||
|
field: 'entity_type',
|
||||||
|
required: true,
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ display: 'User', value: 'user' },
|
||||||
|
{ display: 'Group', value: 'group' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Subject',
|
||||||
|
field: 'entity_id',
|
||||||
|
required: true,
|
||||||
|
type: 'select.dynamic',
|
||||||
|
options: {
|
||||||
|
resource: 'auth/User',
|
||||||
|
display: user => `User: ${user.last_name}, ${user.first_name} (${user.uid})`,
|
||||||
|
value: 'id',
|
||||||
|
},
|
||||||
|
if: (form_data) => form_data.entity_type === 'user',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Subject',
|
||||||
|
field: 'entity_id',
|
||||||
|
required: true,
|
||||||
|
type: 'select.dynamic',
|
||||||
|
options: {
|
||||||
|
resource: 'auth/Group',
|
||||||
|
display: group => `Group: ${group.name} (${group.user_ids.length} users)`,
|
||||||
|
value: 'id',
|
||||||
|
},
|
||||||
|
if: (form_data) => form_data.entity_type === 'group',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Access Type',
|
||||||
|
field: 'access_type',
|
||||||
|
required: true,
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ display: '...is granted access to...', value: 'allow' },
|
||||||
|
{ display: '...is denied access to...', value: 'deny' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Target Type',
|
||||||
|
field: 'target_type',
|
||||||
|
required: true,
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ display: 'Application', value: 'application' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Target',
|
||||||
|
field: 'target_id',
|
||||||
|
required: true,
|
||||||
|
type: 'select.dynamic',
|
||||||
|
options: {
|
||||||
|
resource: 'App',
|
||||||
|
display: 'name',
|
||||||
|
value: 'id',
|
||||||
|
},
|
||||||
|
if: (form_data) => form_data.target_type === 'application'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const iam_policy = new PolicyResource()
|
||||||
|
export { iam_policy }
|
@ -1,13 +1,20 @@
|
|||||||
import CRUDBase from '../CRUDBase.js'
|
import CRUDBase from '../CRUDBase.js'
|
||||||
|
import { session } from '../../service/Session.service.js'
|
||||||
|
|
||||||
class ClientResource extends CRUDBase {
|
class ClientResource extends CRUDBase {
|
||||||
endpoint = '/api/v1/ldap/clients'
|
endpoint = '/api/v1/ldap/clients'
|
||||||
required_fields = ['name', 'uid', 'password']
|
required_fields = ['name', 'uid', 'password']
|
||||||
|
permission_base = 'v1:ldap:clients'
|
||||||
|
|
||||||
item = 'LDAP Client'
|
item = 'LDAP Client'
|
||||||
plural = 'LDAP Clients'
|
plural = 'LDAP Clients'
|
||||||
|
|
||||||
listing_definition = {
|
listing_definition = {
|
||||||
|
display: `
|
||||||
|
LDAP Clients are special user accounts that external applications can use to bind to ${session.get('app.name')}'s built-in LDAP server to allow these applications to authenticate users.
|
||||||
|
<br><br>
|
||||||
|
These special accounts are permitted to bind to the LDAP server, but are not allowed to sign-in to ${session.get('app.name')}.
|
||||||
|
`,
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
name: 'Client Name',
|
name: 'Client Name',
|
||||||
@ -33,6 +40,14 @@ class ClientResource extends CRUDBase {
|
|||||||
icon: 'fa fa-edit',
|
icon: 'fa fa-edit',
|
||||||
color: 'primary',
|
color: 'primary',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'resource',
|
||||||
|
position: 'row',
|
||||||
|
action: 'delete',
|
||||||
|
icon: 'fa fa-times',
|
||||||
|
color: 'danger',
|
||||||
|
confirm: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ import CRUDBase from '../CRUDBase.js'
|
|||||||
class GroupResource extends CRUDBase {
|
class GroupResource extends CRUDBase {
|
||||||
endpoint = '/api/v1/ldap/groups'
|
endpoint = '/api/v1/ldap/groups'
|
||||||
required_fields = ['name', 'role']
|
required_fields = ['name', 'role']
|
||||||
|
permission_base = 'v1:ldap:groups'
|
||||||
|
|
||||||
item = 'LDAP Group'
|
item = 'LDAP Group'
|
||||||
plural = 'LDAP Groups'
|
plural = 'LDAP Groups'
|
||||||
|
107
app/assets/app/resource/oauth/Client.resource.js
Normal file
107
app/assets/app/resource/oauth/Client.resource.js
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import CRUDBase from '../CRUDBase.js'
|
||||||
|
import { session } from '../../service/Session.service.js';
|
||||||
|
|
||||||
|
class ClientResource extends CRUDBase {
|
||||||
|
endpoint = '/api/v1/oauth/clients'
|
||||||
|
required_fields = ['name', 'redirect_url', 'api_scopes']
|
||||||
|
permission_base = 'v1:oauth:clients'
|
||||||
|
|
||||||
|
item = 'OAuth2 Client'
|
||||||
|
plural = 'OAuth2 Clients'
|
||||||
|
|
||||||
|
listing_definition = {
|
||||||
|
display: `
|
||||||
|
OAuth2 clients are applications that support authentication over the OAuth2 protocol. This allows you to add a "Sign-In with XXX" button for ${session.get('app.name')} to the application in question. To do this, you need to create an OAuth2 client for that application, and provide the name, redirect URL, and API scopes.
|
||||||
|
<br><br>
|
||||||
|
You must select the API scopes to grant this OAuth2 client. This defines what ${session.get('app.name')} endpoints the application is allowed to access. For most applications, granting the <code>v1:api:users:get</code> and <code>v1:api:groups:get</code> API scopes should be sufficient.
|
||||||
|
<br><br>
|
||||||
|
This method can also be used to access the API for other purposes. Hence, the expansive API scopes. ${session.get('app.name')} uses Flitter-Auth's built-in OAuth2 server under the hood, so you can find details on how to configure the OAuth2 clients <a href="https://flitter.garrettmills.dev/tutorial-flitter-auth-oauth2-server.html" target="_blank">here.</a>
|
||||||
|
`,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'Client Name',
|
||||||
|
field: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '# of Scopes',
|
||||||
|
field: 'api_scopes',
|
||||||
|
renderer: (api_scopes) => api_scopes.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Redirect URL',
|
||||||
|
field: 'redirect_url',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
form_definition = {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'Client Name',
|
||||||
|
field: 'name',
|
||||||
|
placeholder: 'Awesome External App',
|
||||||
|
required: true,
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Redirect URL',
|
||||||
|
field: 'redirect_url',
|
||||||
|
placeholder: 'https://awesome.app/oauth2/callback',
|
||||||
|
required: true,
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'API Scopes',
|
||||||
|
field: 'api_scopes',
|
||||||
|
type: 'select.dynamic.multiple',
|
||||||
|
options: {
|
||||||
|
resource: 'reflect/Scope',
|
||||||
|
display: 'scope',
|
||||||
|
value: 'scope',
|
||||||
|
},
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Client ID',
|
||||||
|
field: 'uuid',
|
||||||
|
type: 'text',
|
||||||
|
readonly: true,
|
||||||
|
hidden: ['insert'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Client Secret',
|
||||||
|
field: 'secret',
|
||||||
|
type: 'text',
|
||||||
|
readonly: true,
|
||||||
|
hidden: ['insert'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauth_client = new ClientResource()
|
||||||
|
export { oauth_client }
|
13
app/assets/app/resource/reflect/Scope.resource.js
Normal file
13
app/assets/app/resource/reflect/Scope.resource.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import CRUDBase from '../CRUDBase.js'
|
||||||
|
|
||||||
|
class ScopeResource extends CRUDBase {
|
||||||
|
endpoint = '/api/v1/reflect/scopes'
|
||||||
|
required_fields = ['scope']
|
||||||
|
permission_base = 'v1:reflect:scopes'
|
||||||
|
|
||||||
|
item = 'API Scope'
|
||||||
|
plural = 'API Scopes'
|
||||||
|
}
|
||||||
|
|
||||||
|
const reflect_scope = new ScopeResource()
|
||||||
|
export { reflect_scope }
|
89
app/assets/app/resource/reflect/Token.resource.js
Normal file
89
app/assets/app/resource/reflect/Token.resource.js
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import CRUDBase from '../CRUDBase.js'
|
||||||
|
|
||||||
|
class TokenResource extends CRUDBase {
|
||||||
|
endpoint = '/api/v1/reflect/tokens'
|
||||||
|
required_fields = ['client_id']
|
||||||
|
permission_base = 'v1:reflect:tokens'
|
||||||
|
|
||||||
|
item = 'API Token'
|
||||||
|
plural = 'API Tokens'
|
||||||
|
|
||||||
|
listing_definition = {
|
||||||
|
display: `
|
||||||
|
This allows you to create bearer tokens manually to allow for easier testing of the API. Notably, this is meant as a measure for testing and development, not for long term use.
|
||||||
|
<br><br>
|
||||||
|
If you have an application that needs to regularly interact with the API, set it up as an <a href="/dash/c/listing/oauth/Client">OAuth2 Client</a>. Manually-created tokens expire 7 days after their creation.
|
||||||
|
`,
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'Token',
|
||||||
|
field: 'token',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Client',
|
||||||
|
field: 'client_display',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Expires',
|
||||||
|
field: 'expires',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
form_definition = {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'Client',
|
||||||
|
field: 'client_id',
|
||||||
|
required: true,
|
||||||
|
type: 'select.dynamic',
|
||||||
|
options: {
|
||||||
|
resource: 'oauth/Client',
|
||||||
|
display: 'name',
|
||||||
|
value: 'uuid',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Bearer Token',
|
||||||
|
field: 'token',
|
||||||
|
type: 'text',
|
||||||
|
readonly: true,
|
||||||
|
hidden: ['insert'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Expires',
|
||||||
|
field: 'expires',
|
||||||
|
type: 'text',
|
||||||
|
readonly: true,
|
||||||
|
hidden: ['insert'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reflect_token = new TokenResource()
|
||||||
|
export { reflect_token }
|
@ -1,13 +1,18 @@
|
|||||||
import CRUDBase from '../CRUDBase.js'
|
import CRUDBase from '../CRUDBase.js'
|
||||||
|
import { session } from '../../service/Session.service.js'
|
||||||
|
|
||||||
class ProviderResource extends CRUDBase {
|
class ProviderResource extends CRUDBase {
|
||||||
endpoint = '/api/v1/saml/providers'
|
endpoint = '/api/v1/saml/providers'
|
||||||
required_fields = ['name', 'acs_url', 'entity_id']
|
required_fields = ['name', 'acs_url', 'entity_id']
|
||||||
|
permission_base = 'v1:saml:providers'
|
||||||
|
|
||||||
item = 'SAML Service Provider'
|
item = 'SAML Service Provider'
|
||||||
plural = 'SAML Service Providers'
|
plural = 'SAML Service Providers'
|
||||||
|
|
||||||
listing_definition = {
|
listing_definition = {
|
||||||
|
display: `SAML Service Providers are applications that support external authentication to a SAML Identity Provider. In this case, ${session.get('app.name')} is the identity provider, so these external applications can authenticate against it.
|
||||||
|
<br><br>
|
||||||
|
To do this, you need to know the SAML service provider's entity ID, assertion consumer service URL, and single-logout URL (if supported).`,
|
||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
name: 'Provider Name',
|
name: 'Provider Name',
|
||||||
@ -42,6 +47,14 @@ class ProviderResource extends CRUDBase {
|
|||||||
icon: 'fa fa-edit',
|
icon: 'fa fa-edit',
|
||||||
color: 'primary',
|
color: 'primary',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'resource',
|
||||||
|
position: 'row',
|
||||||
|
action: 'delete',
|
||||||
|
icon: 'fa fa-times',
|
||||||
|
color: 'danger',
|
||||||
|
confirm: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,6 +26,12 @@ class Session {
|
|||||||
|
|
||||||
parent[parts.reverse()[0]] = value
|
parent[parts.reverse()[0]] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async check_permissions(...permissions) {
|
||||||
|
const result = await axios.post('/api/v1/reflect/check_permissions', { permissions })
|
||||||
|
if ( permissions.length === 1 ) return result.data.data[permissions[0]]
|
||||||
|
return result.data.data
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = new Session()
|
const session = new Session()
|
||||||
|
272
app/controllers/api/v1/App.controller.js
Normal file
272
app/controllers/api/v1/App.controller.js
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
const { Controller } = require('libflitter')
|
||||||
|
|
||||||
|
class AppController extends Controller {
|
||||||
|
static get services() {
|
||||||
|
return [...super.services, 'models', 'utility']
|
||||||
|
}
|
||||||
|
|
||||||
|
async get_applications(req, res, next) {
|
||||||
|
const Application = this.models.get('Application')
|
||||||
|
const applications = await Application.find({ active: true })
|
||||||
|
const data = []
|
||||||
|
|
||||||
|
for ( const app of applications ) {
|
||||||
|
if ( req.user.can(`app:${app.id}:view`) ) {
|
||||||
|
data.push(await app.to_api())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.api(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async get_application(req, res, next) {
|
||||||
|
const Application = this.models.get('Application')
|
||||||
|
const application = await Application.findById(req.params.id)
|
||||||
|
|
||||||
|
if ( !application || !application.active )
|
||||||
|
return res.status(404)
|
||||||
|
.message('Application not found with that ID.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !req.user.can(`app:${application.id}:view`) )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
return res.api(await application.to_api())
|
||||||
|
}
|
||||||
|
|
||||||
|
async create_application(req, res, next) {
|
||||||
|
const Application = this.models.get('Application')
|
||||||
|
|
||||||
|
if ( !req.user.can('app:create') )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const required_fields = ['name', 'identifier']
|
||||||
|
for ( const field of required_fields ) {
|
||||||
|
if ( !req.body[field] )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`Missing required field: ${field}`)
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the identifier is properly formatted
|
||||||
|
if ( !(new RegExp('^[a-zA-Z0-9_]*$')).test(req.body.identifier) )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Improperly formatted field: identifier (alphanumeric with underscores only)')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
// Make sure the identifier is unique
|
||||||
|
const existing_app = await Application.findOne({ identifier: req.body.identifier })
|
||||||
|
if ( existing_app )
|
||||||
|
return res.status(400)
|
||||||
|
.message('An Application with that identifier already exists.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const application = new Application({
|
||||||
|
name: req.body.name,
|
||||||
|
identifier: req.body.identifier,
|
||||||
|
description: req.body.description,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify LDAP client IDs
|
||||||
|
const LDAPClient = this.models.get('ldap:Client')
|
||||||
|
if ( req.body.ldap_client_ids ) {
|
||||||
|
const parsed = typeof req.body.ldap_client_ids === 'string' ? this.utility.infer(req.body.ldap_client_ids) : req.body.ldap_client_ids
|
||||||
|
const ldap_client_ids = Array.isArray(parsed) ? parsed : [parsed]
|
||||||
|
for ( const id of ldap_client_ids ) {
|
||||||
|
const client = await LDAPClient.findById(id)
|
||||||
|
if ( !client || !client.active || !req.user.can(`ldap:client:${client.id}:view`) )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`Invalid ldap_client_id: ${id}`)
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const other_assoc_app = await Application.findOne({ ldap_client_ids: client.id })
|
||||||
|
if ( other_assoc_app )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`The LDAP client ${client.name} is already associated with an existing application (${other_assoc_app.name}).`)
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
application.ldap_client_ids = ldap_client_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify OAuth client IDs
|
||||||
|
const OAuthClient = this.models.get('oauth:Client')
|
||||||
|
if ( req.body.oauth_client_ids ) {
|
||||||
|
const parsed = typeof req.body.oauth_client_ids === 'string' ? this.utility.infer(req.body.oauth_client_ids) : req.body.oauth_client_ids
|
||||||
|
const oauth_client_ids = Array.isArray(parsed) ? parsed : [parsed]
|
||||||
|
for ( const id of oauth_client_ids ) {
|
||||||
|
const client = await OAuthClient.findById(id)
|
||||||
|
if ( !client || !client.active || !req.user.can(`oauth:client:${client.id}:view`) )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`Invalid oauth_client_id: ${id}`)
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const other_assoc_app = await Application.findOne({ oauth_client_ids: client.id })
|
||||||
|
if ( other_assoc_app )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`The OAuth2 client ${client.name} is already associated with an existing application (${other_assoc_app.name}).`)
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
application.oauth_client_ids = oauth_client_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify SAML service provider IDs
|
||||||
|
const ServiceProvider = this.models.get('saml:ServiceProvider')
|
||||||
|
if ( req.body.saml_service_provider_ids ) {
|
||||||
|
const parsed = typeof req.body.saml_service_provider_ids === 'string' ? this.utility.infer(req.body.saml_service_provider_ids) : req.body.saml_service_provider_ids
|
||||||
|
const saml_service_provider_ids = Array.isArray(parsed) ? parsed : [parsed]
|
||||||
|
for ( const id of saml_service_provider_ids ) {
|
||||||
|
const provider = await ServiceProvider.findById(id)
|
||||||
|
if ( !provider || !provider.active || !req.user.can(`saml:provider:${provider.id}:view`) )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`Invalid saml_service_provider_id: ${id}`)
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const other_assoc_app = await Application.findOne({ saml_service_provider_ids: provider.id })
|
||||||
|
if ( other_assoc_app )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`The SAML service provider ${provider.name} is already associated with an existing application (${other_assoc_app.name}).`)
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
application.saml_service_provider_ids = saml_service_provider_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
await application.save()
|
||||||
|
return res.api(await application.to_api())
|
||||||
|
}
|
||||||
|
|
||||||
|
async update_application(req, res, next) {
|
||||||
|
const Application = this.models.get('Application')
|
||||||
|
const application = await Application.findById(req.params.id)
|
||||||
|
|
||||||
|
if ( !application || !application.active )
|
||||||
|
return res.status(404)
|
||||||
|
.message('Application not found with that ID.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !req.user.can(`app:${application.id}:update`) )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const required_fields = ['name', 'identifier']
|
||||||
|
for ( const field of required_fields ) {
|
||||||
|
if ( !req.body[field] )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`Missing required field: ${field}`)
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the identifier is properly formatted
|
||||||
|
if ( !(new RegExp('^[a-zA-Z0-9_]*$')).test(req.body.identifier) )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Improperly formatted field: identifier (alphanumeric with underscores only)')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
// Make sure the identifier is unique
|
||||||
|
const existing_app = await Application.findOne({ identifier: req.body.identifier })
|
||||||
|
if ( existing_app && existing_app.id !== application.id )
|
||||||
|
return res.status(400)
|
||||||
|
.message('An Application with that identifier already exists.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
// Verify LDAP client IDs
|
||||||
|
const LDAPClient = this.models.get('ldap:Client')
|
||||||
|
if ( req.body.ldap_client_ids ) {
|
||||||
|
const parsed = typeof req.body.ldap_client_ids === 'string' ? this.utility.infer(req.body.ldap_client_ids) : req.body.ldap_client_ids
|
||||||
|
const ldap_client_ids = Array.isArray(parsed) ? parsed : [parsed]
|
||||||
|
for ( const id of ldap_client_ids ) {
|
||||||
|
const client = await LDAPClient.findById(id)
|
||||||
|
if ( !client || !client.active || !req.user.can(`ldap:client:${client.id}:view`) )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`Invalid ldap_client_id: ${id}`)
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const other_assoc_app = await Application.findOne({ ldap_client_ids: client.id })
|
||||||
|
if ( other_assoc_app && other_assoc_app.id !== application.id )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`The LDAP client ${client.name} is already associated with an existing application (${other_assoc_app.name}).`)
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
application.ldap_client_ids = ldap_client_ids
|
||||||
|
} else application.ldap_client_ids = []
|
||||||
|
|
||||||
|
// Verify OAuth client IDs
|
||||||
|
const OAuthClient = this.models.get('oauth:Client')
|
||||||
|
if ( req.body.oauth_client_ids ) {
|
||||||
|
const parsed = typeof req.body.oauth_client_ids === 'string' ? this.utility.infer(req.body.oauth_client_ids) : req.body.oauth_client_ids
|
||||||
|
const oauth_client_ids = Array.isArray(parsed) ? parsed : [parsed]
|
||||||
|
for ( const id of oauth_client_ids ) {
|
||||||
|
const client = await OAuthClient.findById(id)
|
||||||
|
if ( !client || !client.active || !req.user.can(`oauth:client:${client.id}:view`) )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`Invalid oauth_client_id: ${id}`)
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const other_assoc_app = await Application.findOne({ oauth_client_ids: client.id })
|
||||||
|
if ( other_assoc_app && other_assoc_app.id !== application.id )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`The OAuth2 client ${client.name} is already associated with an existing application (${other_assoc_app.name}).`)
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
application.oauth_client_ids = oauth_client_ids
|
||||||
|
} else application.oauth_client_ids = []
|
||||||
|
|
||||||
|
// Verify SAML service provider IDs
|
||||||
|
const ServiceProvider = this.models.get('saml:ServiceProvider')
|
||||||
|
if ( req.body.saml_service_provider_ids ) {
|
||||||
|
const parsed = typeof req.body.saml_service_provider_ids === 'string' ? this.utility.infer(req.body.saml_service_provider_ids) : req.body.saml_service_provider_ids
|
||||||
|
const saml_service_provider_ids = Array.isArray(parsed) ? parsed : [parsed]
|
||||||
|
for ( const id of saml_service_provider_ids ) {
|
||||||
|
const provider = await ServiceProvider.findById(id)
|
||||||
|
if ( !provider || !provider.active || !req.user.can(`saml:provider:${provider.id}:view`) )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`Invalid saml_service_provider_id: ${id}`)
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const other_assoc_app = await Application.findOne({ saml_service_provider_ids: provider.id })
|
||||||
|
if ( other_assoc_app && other_assoc_app.id !== application.id )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`The SAML service provider ${provider.name} is already associated with an existing application (${other_assoc_app.name}).`)
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
application.saml_service_provider_ids = saml_service_provider_ids
|
||||||
|
} else application.saml_service_provider_ids = []
|
||||||
|
|
||||||
|
application.name = req.body.name
|
||||||
|
application.identifier = req.body.identifier
|
||||||
|
application.description = req.body.description
|
||||||
|
await application.save()
|
||||||
|
return res.api(await application.to_api())
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete_application(req, res, next) {
|
||||||
|
const Application = this.models.get('Application')
|
||||||
|
const application = await Application.findById(req.params.id)
|
||||||
|
|
||||||
|
if ( !application || !application.active )
|
||||||
|
return res.status(404)
|
||||||
|
.message('Application not found with that ID.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !req.user.can(`app:${application.id}:delete`) )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
application.active = false
|
||||||
|
await application.save()
|
||||||
|
return res.api()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = exports = AppController
|
@ -1,8 +1,9 @@
|
|||||||
const { Controller } = require('libflitter')
|
const { Controller } = require('libflitter')
|
||||||
|
const zxcvbn = require('zxcvbn')
|
||||||
|
|
||||||
class AuthController extends Controller {
|
class AuthController extends Controller {
|
||||||
static get services() {
|
static get services() {
|
||||||
return [...super.services, 'models', 'auth', 'MFA', 'output', 'configs']
|
return [...super.services, 'models', 'auth', 'MFA', 'output', 'configs', 'utility']
|
||||||
}
|
}
|
||||||
|
|
||||||
async get_users(req, res, next) {
|
async get_users(req, res, next) {
|
||||||
@ -18,6 +19,20 @@ class AuthController extends Controller {
|
|||||||
return res.api(data)
|
return res.api(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async get_groups(req, res, next) {
|
||||||
|
const Group = this.models.get('auth:Group')
|
||||||
|
const groups = await Group.find({active: true})
|
||||||
|
const data = []
|
||||||
|
|
||||||
|
for ( const group of groups ) {
|
||||||
|
if ( !req.user.can(`auth:group:${group.id}:view`) ) continue
|
||||||
|
data.push(await group.to_api())
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.api(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async get_roles(req, res, next) {
|
async get_roles(req, res, next) {
|
||||||
const role_config = this.configs.get('auth.roles')
|
const role_config = this.configs.get('auth.roles')
|
||||||
const data = []
|
const data = []
|
||||||
@ -32,6 +47,291 @@ class AuthController extends Controller {
|
|||||||
return res.api(data)
|
return res.api(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async get_group(req, res, next) {
|
||||||
|
const Group = this.models.get('auth:Group')
|
||||||
|
const group = await Group.findById(req.params.id)
|
||||||
|
|
||||||
|
if ( !group || !group.active )
|
||||||
|
return res.status(404)
|
||||||
|
.message('Group not found with that ID.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !req.user.can(`auth:group:${group.id}:view`) )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
return res.api(await group.to_api())
|
||||||
|
}
|
||||||
|
|
||||||
|
async get_user(req, res, next) {
|
||||||
|
if ( req.params.id === 'me' )
|
||||||
|
return res.api(await req.user.to_api())
|
||||||
|
|
||||||
|
const User = this.models.get('auth:User')
|
||||||
|
const user = await User.findById(req.params.id)
|
||||||
|
|
||||||
|
if ( !user )
|
||||||
|
return res.status(404)
|
||||||
|
.message('User not found with that ID.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !req.user.can(`auth:user:${user.id}:view`) )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
return res.api(await user.to_api())
|
||||||
|
}
|
||||||
|
|
||||||
|
async create_group(req, res, next) {
|
||||||
|
if ( !req.user.can(`auth:group:create`) )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !req.body.name )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Missing required field: name')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const Group = this.models.get('auth:Group')
|
||||||
|
|
||||||
|
// Make sure the name is free
|
||||||
|
const existing_group = await Group.findOne({ name: req.body.name })
|
||||||
|
if ( existing_group )
|
||||||
|
return res.status(400)
|
||||||
|
.message('A group with that name already exists.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const group = new Group({ name: req.body.name })
|
||||||
|
|
||||||
|
// Validate user ids
|
||||||
|
const User = this.models.get('auth:User')
|
||||||
|
if ( req.body.user_ids ) {
|
||||||
|
const parsed = typeof req.body.user_ids === 'string' ? this.utility.infer(req.body.user_ids) : req.body.user_ids
|
||||||
|
const user_ids = Array.isArray(parsed) ? parsed : [parsed]
|
||||||
|
for ( const user_id of user_ids ) {
|
||||||
|
const user = await User.findById(user_id)
|
||||||
|
if ( !user )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Invalid user_id.')
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
group.user_ids = user_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
await group.save()
|
||||||
|
return res.api(await group.to_api())
|
||||||
|
}
|
||||||
|
|
||||||
|
async create_user(req, res, next) {
|
||||||
|
if ( !req.user.can('auth:user:create') )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const required_fields = ['uid', 'first_name', 'last_name', 'email', 'password']
|
||||||
|
for ( const field of required_fields ) {
|
||||||
|
if ( !req.body[field] )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`Missing required field: ${field}`)
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure uid & email are unique
|
||||||
|
const User = this.models.get('auth:User')
|
||||||
|
const unique_fields = ['uid', 'email']
|
||||||
|
for ( const field of unique_fields ) {
|
||||||
|
const filter = {}
|
||||||
|
filter[field] = req.body[field]
|
||||||
|
const existing_user = await User.findOne(filter)
|
||||||
|
if ( existing_user )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`A user already exists with that ${field}`)
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password complexity
|
||||||
|
const min_score = 3
|
||||||
|
const result = zxcvbn(req.body.password)
|
||||||
|
if ( result.score < min_score )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`Password does not meet the minimum complexity score of ${min_score}.`)
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const user = new User({
|
||||||
|
uid: req.body.uid,
|
||||||
|
email: req.body.email,
|
||||||
|
first_name: req.body.first_name,
|
||||||
|
last_name: req.body.last_name,
|
||||||
|
})
|
||||||
|
|
||||||
|
if ( req.body.tagline )
|
||||||
|
user.tagline = req.body.tagline
|
||||||
|
|
||||||
|
await user.reset_password(req.body.password, 'create')
|
||||||
|
await user.save()
|
||||||
|
return res.api(await user.to_api())
|
||||||
|
}
|
||||||
|
|
||||||
|
async update_group(req, res, next) {
|
||||||
|
const Group = this.models.get('auth:Group')
|
||||||
|
const User = this.models.get('auth:User')
|
||||||
|
|
||||||
|
const group = await Group.findById(req.params.id)
|
||||||
|
if ( !group )
|
||||||
|
return res.status(404)
|
||||||
|
.message('Group not found with that ID.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !req.user.can(`auth:group:${group.id}:update`) )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !req.body.name )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Missing required field: name')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
// Make sure the group name is unique
|
||||||
|
const existing_group = await Group.findOne({ name: req.body.name })
|
||||||
|
if ( existing_group && existing_group.id !== group.id )
|
||||||
|
return res.status(400)
|
||||||
|
.message('A group with that name already exists.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
// Validate user_ids
|
||||||
|
if ( req.body.user_ids ) {
|
||||||
|
const parsed = typeof req.body.user_ids === 'string' ? this.utility.infer(req.body.user_ids) : req.body.user_ids
|
||||||
|
const user_ids = Array.isArray(parsed) ? parsed : [parsed]
|
||||||
|
for ( const user_id of user_ids ) {
|
||||||
|
const user = await User.findById(user_id)
|
||||||
|
if ( !user )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Invalid user_id.')
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
group.user_ids = user_ids
|
||||||
|
} else {
|
||||||
|
group.user_ids = []
|
||||||
|
}
|
||||||
|
|
||||||
|
group.name = req.body.name
|
||||||
|
await group.save()
|
||||||
|
return res.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
async update_user(req, res, next) {
|
||||||
|
const User = this.models.get('auth:User')
|
||||||
|
const user = await User.findById(req.params.id)
|
||||||
|
|
||||||
|
if ( !user )
|
||||||
|
return res.status(404)
|
||||||
|
.message('User not found with that ID.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !req.user.can(`auth:user:${user.id}:update`) )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const required_fields = ['uid', 'first_name', 'last_name', 'email']
|
||||||
|
for ( const field of required_fields ) {
|
||||||
|
if ( !req.body[field] )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`Missing required field: ${field}`)
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the uid/email are unique
|
||||||
|
const unique_fields = ['uid', 'email']
|
||||||
|
for ( const field of unique_fields ) {
|
||||||
|
const filter = {}
|
||||||
|
filter[field] = req.body[field]
|
||||||
|
const existing_user = await User.findOne(filter)
|
||||||
|
if ( existing_user && existing_user.id !== user.id )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`A user already exists with that ${field}`)
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password complexity
|
||||||
|
if ( req.body.password ) {
|
||||||
|
const min_score = 3
|
||||||
|
const result = zxcvbn(req.body.password)
|
||||||
|
if (result.score < min_score)
|
||||||
|
return res.status(400)
|
||||||
|
.message(`Password does not meet the minimum complexity score of ${min_score}.`)
|
||||||
|
.api()
|
||||||
|
|
||||||
|
await user.reset_password(req.body.password, 'api')
|
||||||
|
}
|
||||||
|
|
||||||
|
user.first_name = req.body.first_name
|
||||||
|
user.last_name = req.body.last_name
|
||||||
|
user.uid = req.body.uid
|
||||||
|
user.email = req.body.email
|
||||||
|
|
||||||
|
if ( req.body.tagline )
|
||||||
|
user.tagline = req.body.tagline
|
||||||
|
else
|
||||||
|
user.tagline = ''
|
||||||
|
|
||||||
|
await user.save()
|
||||||
|
return res.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete_group(req, res, next) {
|
||||||
|
const Group = this.models.get('auth:Group')
|
||||||
|
const group = await Group.findById(req.params.id)
|
||||||
|
|
||||||
|
if ( !group )
|
||||||
|
return res.status(404)
|
||||||
|
.message('Group not found with that ID.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !req.user.can(`auth:group:${group.id}:delete`) )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
group.active = false
|
||||||
|
await group.save()
|
||||||
|
return res.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete_user(req, res, next) {
|
||||||
|
const User = this.models.get('auth:User')
|
||||||
|
const user = await User.findById(req.params.id)
|
||||||
|
|
||||||
|
if ( !user )
|
||||||
|
return res.status(404)
|
||||||
|
.message('User not found with that ID.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !req.user.can(`auth:user:${user.id}:delete`) )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
// check if the user is an LDAP client. if so, delete the client
|
||||||
|
const Client = this.models.get('ldap:Client')
|
||||||
|
const matching_client = await Client.findOne({ user_id: user.id })
|
||||||
|
if ( matching_client ) {
|
||||||
|
matching_client.active = false
|
||||||
|
await matching_client.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
user.active = false
|
||||||
|
await user.kickout()
|
||||||
|
await user.save()
|
||||||
|
return res.api()
|
||||||
|
}
|
||||||
|
|
||||||
async validate_username(req, res, next) {
|
async validate_username(req, res, next) {
|
||||||
let is_valid = true
|
let is_valid = true
|
||||||
|
|
||||||
@ -73,6 +373,18 @@ class AuthController extends Controller {
|
|||||||
success: false,
|
success: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Make sure the user can sign in.
|
||||||
|
// Sign-in is NOT allowed for LDAP clients
|
||||||
|
const Client = this.models.get('ldap:Client')
|
||||||
|
const client = await Client.findOne({ user_id: user.id })
|
||||||
|
if ( client )
|
||||||
|
return res.status(200)
|
||||||
|
.message(`Invalid username or password.`)
|
||||||
|
.api({
|
||||||
|
message: `Invalid username or password.`,
|
||||||
|
success: false,
|
||||||
|
})
|
||||||
|
|
||||||
if ( req.body.create_session )
|
if ( req.body.create_session )
|
||||||
await flitter.session(req, user)
|
await flitter.session(req, user)
|
||||||
|
|
||||||
|
236
app/controllers/api/v1/IAM.controller.js
Normal file
236
app/controllers/api/v1/IAM.controller.js
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
const { Controller } = require('libflitter')
|
||||||
|
|
||||||
|
class IAMController extends Controller {
|
||||||
|
static get services() {
|
||||||
|
return [...super.services, 'models']
|
||||||
|
}
|
||||||
|
|
||||||
|
async check_entity_access(req, res, next) {
|
||||||
|
const Policy = this.models.get('iam:Policy')
|
||||||
|
|
||||||
|
if ( !req.body.entity_id && !req.body.target_id )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Missing one or more required fields: entity_id, target_id')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
return res.api(await Policy.check_entity_access(req.body.entity_id, req.body.target_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async check_user_access(req, res, next) {
|
||||||
|
const User = this.models.get('auth:User')
|
||||||
|
const Policy = this.models.get('iam:Policy')
|
||||||
|
|
||||||
|
if ( !req.body.target_id )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Missing required field: target_id')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
let user = req.user
|
||||||
|
if ( req.body.user_id && req.body.user_id !== 'me' )
|
||||||
|
user = await User.findById(req.body.user_id)
|
||||||
|
|
||||||
|
if ( !user )
|
||||||
|
return res.status(404)
|
||||||
|
.message('User not found with that ID.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !req.user.can(`auth:user:${user.id}:view`) )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
return res.api(await Policy.check_user_access(user, req.body.target_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async get_policies(req, res, next) {
|
||||||
|
const Policy = this.models.get('iam:Policy')
|
||||||
|
const policies = await Policy.find({ active: true })
|
||||||
|
const data = []
|
||||||
|
|
||||||
|
for ( const policy of policies ) {
|
||||||
|
if ( req.user.can(`iam:policy:${policy.id}:view`) ) {
|
||||||
|
data.push(await policy.to_api())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.api(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async get_policy(req, res, next) {
|
||||||
|
const Policy = this.models.get('iam:Policy')
|
||||||
|
const policy = await Policy.findById(req.params.id)
|
||||||
|
|
||||||
|
if ( !policy )
|
||||||
|
return res.status(404)
|
||||||
|
.message('Policy not found with that ID.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !req.user.can(`iam:policy:${policy.id}:view`) )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
return res.api(await policy.to_api())
|
||||||
|
}
|
||||||
|
|
||||||
|
async create_policy(req, res, next) {
|
||||||
|
const Policy = this.models.get('iam:Policy')
|
||||||
|
|
||||||
|
const required_fields = ['entity_type', 'entity_id', 'access_type', 'target_type', 'target_id']
|
||||||
|
for ( const field of required_fields ) {
|
||||||
|
if ( !req.body[field] )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`Missing required field: ${field}`)
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !['user', 'group'].includes(req.body.entity_type) )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Invalid entity_type. Must be one of: user, group.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
// Make sure the entity_id is valid
|
||||||
|
if ( req.body.entity_type === 'user' ) {
|
||||||
|
const User = this.models.get('auth:User')
|
||||||
|
const user = await User.findById(req.body.entity_id)
|
||||||
|
if ( !user || !req.user.can(`auth:user:${user.id}:view`) )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Invalid entity_id.')
|
||||||
|
.api()
|
||||||
|
} else if ( req.body.entity_type === 'group' ) {
|
||||||
|
const Group = this.models.get('auth:Group')
|
||||||
|
const group = await Group.findById(req.body.entity_id)
|
||||||
|
if ( !group || !group.active || !req.user.can(`auth:group:${group.id}:view`) )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Invalid entity_id.')
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !['allow', 'deny'].includes(req.body.access_type) )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Invalid access_type. Must be one of: allow, deny.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !['application'].includes(req.body.target_type) )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Invalid target_type. Must be one of: application.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
// Make sure the target_id is valid
|
||||||
|
if ( req.body.target_type === 'application' ) {
|
||||||
|
const Application = this.models.get('Application')
|
||||||
|
const app = await Application.findById(req.body.target_id)
|
||||||
|
if ( !app || !app.active || !req.user.can(`app:${app.id}:view`) )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Invalid target_id.')
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = new Policy({
|
||||||
|
entity_type: req.body.entity_type,
|
||||||
|
entity_id: req.body.entity_id,
|
||||||
|
access_type: req.body.access_type,
|
||||||
|
target_type: req.body.target_type,
|
||||||
|
target_id: req.body.target_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
await policy.save()
|
||||||
|
req.user.allow(`iam:policy:${policy.id}`)
|
||||||
|
await req.user.save()
|
||||||
|
return res.api(await policy.to_api())
|
||||||
|
}
|
||||||
|
|
||||||
|
async update_policy(req, res, next) {
|
||||||
|
const Policy = this.models.get('iam:Policy')
|
||||||
|
const policy = await Policy.findById(req.params.id)
|
||||||
|
|
||||||
|
if ( !policy || !policy.active )
|
||||||
|
return res.status(404)
|
||||||
|
.message('Policy not found with that ID.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !req.user.can(`iam:policy:${policy.id}:update`) )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const required_fields = ['entity_type', 'entity_id', 'access_type', 'target_type', 'target_id']
|
||||||
|
for ( const field of required_fields ) {
|
||||||
|
if ( !req.body[field] )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`Missing required field: ${field}`)
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !['user', 'group'].includes(req.body.entity_type) )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Invalid entity_type. Must be one of: user, group.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
// Make sure the entity_id is valid
|
||||||
|
if ( req.body.entity_type === 'user' ) {
|
||||||
|
const User = this.models.get('auth:User')
|
||||||
|
const user = await User.findById(req.body.entity_id)
|
||||||
|
if ( !user || !req.user.can(`auth:user:${user.id}:view`) )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Invalid entity_id.')
|
||||||
|
.api()
|
||||||
|
} else if ( req.body.entity_type === 'group' ) {
|
||||||
|
const Group = this.models.get('auth:Group')
|
||||||
|
const group = await Group.findById(req.body.entity_id)
|
||||||
|
if ( !group || !group.active || !req.user.can(`auth:group:${group.id}:view`) )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Invalid entity_id.')
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !['allow', 'deny'].includes(req.body.access_type) )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Invalid access_type. Must be one of: allow, deny.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !['application'].includes(req.body.target_type) )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Invalid target_type. Must be one of: application.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
// Make sure the target_id is valid
|
||||||
|
if ( req.body.target_type === 'application' ) {
|
||||||
|
const Application = this.models.get('Application')
|
||||||
|
const app = await Application.findById(req.body.target_id)
|
||||||
|
if ( !app || !app.active || !req.user.can(`app:${app.id}:view`) )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Invalid target_id.')
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
policy.entity_type = req.body.entity_type
|
||||||
|
policy.entity_id = req.body.entity_id
|
||||||
|
policy.access_type = req.body.access_type
|
||||||
|
policy.target_type = req.body.target_type
|
||||||
|
policy.target_id = req.body.target_id
|
||||||
|
await policy.save()
|
||||||
|
return res.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete_policy(req, res, next) {
|
||||||
|
const Policy = this.models.get('iam:Policy')
|
||||||
|
const policy = await Policy.findById(req.params.id)
|
||||||
|
|
||||||
|
if ( !policy || !policy.active )
|
||||||
|
return res.status(404)
|
||||||
|
.message('Policy not found with that ID.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !req.user.can(`iam:policy:${policy.id}:delete`) )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
policy.active = false
|
||||||
|
await policy.save()
|
||||||
|
return res.api()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = exports = IAMController
|
136
app/controllers/api/v1/OAuth.controller.js
Normal file
136
app/controllers/api/v1/OAuth.controller.js
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
const { Controller } = require('libflitter')
|
||||||
|
const is_absolute_url = require('is-absolute-url')
|
||||||
|
|
||||||
|
class OAuthController extends Controller {
|
||||||
|
static get services() {
|
||||||
|
return [...super.services, 'models']
|
||||||
|
}
|
||||||
|
|
||||||
|
async get_clients(req, res, next) {
|
||||||
|
const Client = this.models.get('oauth:Client')
|
||||||
|
const clients = await Client.find({ active: true })
|
||||||
|
const data = []
|
||||||
|
|
||||||
|
for ( const client of clients ) {
|
||||||
|
if ( req.user.can(`oauth:client:${client.id}:view`) ) {
|
||||||
|
data.push(await client.to_api())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.api(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async get_client(req, res, next) {
|
||||||
|
const Client = this.models.get('oauth:Client')
|
||||||
|
const client = await Client.findById(req.params.id)
|
||||||
|
|
||||||
|
if ( !client || !client.active )
|
||||||
|
return res.status(404)
|
||||||
|
.message('Client not found with that ID.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !req.user.can(`oauth:client:${client.id}:view`) )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
return res.api(await client.to_api())
|
||||||
|
}
|
||||||
|
|
||||||
|
async create_client(req, res, next) {
|
||||||
|
if ( !req.user.can('oauth:client:create') )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const required_fields = ['name', 'api_scopes', 'redirect_url']
|
||||||
|
for ( const field of required_fields ) {
|
||||||
|
if ( !req.body[field] )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`Missing required field: ${field}`)
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !Array.isArray(req.body.api_scopes) ) {
|
||||||
|
return res.status(400)
|
||||||
|
.message(`Improperly formatted field: api_scopes (should be array)`)
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !is_absolute_url(req.body.redirect_url) )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`Improperly formatted field: redirect_url (should be absolute URL)`)
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const Client = this.models.get('oauth:Client')
|
||||||
|
const client = new Client({
|
||||||
|
name: req.body.name,
|
||||||
|
api_scopes: req.body.api_scopes,
|
||||||
|
redirect_url: req.body.redirect_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
await client.save()
|
||||||
|
return res.api(await client.to_api())
|
||||||
|
}
|
||||||
|
|
||||||
|
async update_client(req, res, next) {
|
||||||
|
const Client = this.models.get('oauth:Client')
|
||||||
|
const client = await Client.findById(req.params.id)
|
||||||
|
|
||||||
|
if ( !client || !client.active )
|
||||||
|
return res.status(404)
|
||||||
|
.message('Client not found with that ID.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !req.user.can(`oauth:client:${client.id}:update`) )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const required_fields = ['name', 'api_scopes', 'redirect_url']
|
||||||
|
for ( const field of required_fields ) {
|
||||||
|
if ( !req.body[field] )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`Missing required field: ${field}`)
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !Array.isArray(req.body.api_scopes) )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`Improperly formatted field: api_scopes (should be array)`)
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !is_absolute_url(req.body.redirect_url) )
|
||||||
|
return res.status(400)
|
||||||
|
.message(`Improperly formatted field: redirect_url (should be absolute URL)`)
|
||||||
|
.api()
|
||||||
|
|
||||||
|
client.name = req.body.name
|
||||||
|
client.api_scopes = req.body.api_scopes
|
||||||
|
client.redirect_url = req.body.redirect_url
|
||||||
|
|
||||||
|
await client.save()
|
||||||
|
return res.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete_client(req, res, next) {
|
||||||
|
const Client = this.models.get('oauth:Client')
|
||||||
|
const client = await Client.findById(req.params.id)
|
||||||
|
|
||||||
|
if ( !client || !client.active )
|
||||||
|
return res.status(404)
|
||||||
|
.message('Client not found with that ID.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !req.user.can(`oauth:client:${client.id}:delete`) )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
client.active = false
|
||||||
|
await client.save()
|
||||||
|
return res.api()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = exports = OAuthController
|
184
app/controllers/api/v1/Reflect.controller.js
Normal file
184
app/controllers/api/v1/Reflect.controller.js
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
const { Controller } = require('libflitter')
|
||||||
|
const uuid = require('uuid/v4')
|
||||||
|
|
||||||
|
class ReflectController extends Controller {
|
||||||
|
static get services() {
|
||||||
|
return [...super.services, 'routers', 'models']
|
||||||
|
}
|
||||||
|
|
||||||
|
async get_tokens(req, res, next) {
|
||||||
|
const Oauth2BearerToken = this.models.get('auth::Oauth2BearerToken')
|
||||||
|
const tokens = await Oauth2BearerToken.find({
|
||||||
|
expires: { $gt: new Date },
|
||||||
|
userID: req.user.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const Client = this.models.get('oauth:Client')
|
||||||
|
const data = []
|
||||||
|
for ( const token of tokens ) {
|
||||||
|
const client = await Client.findOne({ uuid: token.clientID })
|
||||||
|
let client_display = client && client.active ? client.name : '(Non-existent Client)'
|
||||||
|
|
||||||
|
data.push({
|
||||||
|
id: token.id,
|
||||||
|
token: token.accessToken,
|
||||||
|
client_id: token.clientID,
|
||||||
|
client_display,
|
||||||
|
expires: token.expires,
|
||||||
|
user_id: token.userID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.api(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async get_token(req, res, next) {
|
||||||
|
const Oauth2BearerToken = this.models.get('auth::Oauth2BearerToken')
|
||||||
|
const token = await Oauth2BearerToken.findById(req.params.id)
|
||||||
|
|
||||||
|
if ( !token || token.userID !== req.user.id || token.expires <= new Date )
|
||||||
|
return res.status(404)
|
||||||
|
.message('Token not found with that ID, or expired.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
return res.api({
|
||||||
|
id: token.id,
|
||||||
|
token: token.accessToken,
|
||||||
|
client_id: token.clientID,
|
||||||
|
expires: token.expires,
|
||||||
|
user_id: token.userID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async create_token(req, res, next) {
|
||||||
|
const Oauth2BearerToken = this.models.get('auth::Oauth2BearerToken')
|
||||||
|
|
||||||
|
if ( !req.body.client_id )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Missing required field: client_id')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const Client = this.models.get('oauth:Client')
|
||||||
|
const client = await Client.findOne({uuid: req.body.client_id})
|
||||||
|
if ( !client || !client.active )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Invalid client_id.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !req.user.can(`oauth:client:${client.id}:view`) )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const expires = new Date()
|
||||||
|
expires.setDate(expires.getDate() + 7)
|
||||||
|
|
||||||
|
const token = new Oauth2BearerToken({
|
||||||
|
accessToken: String(uuid()).replace(/-/g, ''),
|
||||||
|
clientID: client.uuid,
|
||||||
|
expires,
|
||||||
|
userID: req.user.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
await token.save()
|
||||||
|
return res.api({
|
||||||
|
id: token.id,
|
||||||
|
token: token.accessToken,
|
||||||
|
client_id: token.clientID,
|
||||||
|
expires: token.expires,
|
||||||
|
user_id: token.userID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async update_token(req, res, next) {
|
||||||
|
const Oauth2BearerToken = this.models.get('auth::Oauth2BearerToken')
|
||||||
|
const token = await Oauth2BearerToken.findById(req.params.id)
|
||||||
|
|
||||||
|
if ( !token || token.userID !== req.user.id || token.expires <= new Date )
|
||||||
|
return res.status(404)
|
||||||
|
.message('Token not found with that ID, or expired.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
if ( !req.body.client_id )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Missing required field: client_id')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const Client = this.models.get('oauth:Client')
|
||||||
|
const client = await Client.findOne({uuid: req.body.client_id})
|
||||||
|
|
||||||
|
if ( !client || !client.active || !req.user.can(`oauth:client:${client.id}:view`) )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Invalid client_id.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
token.client_id = client.uuid
|
||||||
|
await token.save()
|
||||||
|
return res.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete_token(req, res, next) {
|
||||||
|
const Oauth2BearerToken = this.models.get('auth::Oauth2BearerToken')
|
||||||
|
const token = await Oauth2BearerToken.findById(req.params.id)
|
||||||
|
|
||||||
|
if ( !token || token.userID !== req.user.id || token.expires <= new Date )
|
||||||
|
return res.status(404)
|
||||||
|
.message('Token not found with that ID, or expired.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
await token.delete()
|
||||||
|
return res.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
async get_scopes(req, res, next) {
|
||||||
|
const routers = this.routers.canonical_items
|
||||||
|
const scopes = []
|
||||||
|
|
||||||
|
for ( const prefix in routers ) {
|
||||||
|
if ( !routers.hasOwnProperty(prefix) ) continue
|
||||||
|
const router = routers[prefix].schema
|
||||||
|
|
||||||
|
const supported_verbs = ['get', 'post', 'put', 'delete', 'copy', 'patch']
|
||||||
|
for ( const verb of supported_verbs ) {
|
||||||
|
if ( typeof router[verb] === 'object' ) {
|
||||||
|
const defs = router[verb]
|
||||||
|
for ( const def of Object.values(defs) ) {
|
||||||
|
if ( Array.isArray(def) ) {
|
||||||
|
for ( const layer of def ) {
|
||||||
|
if ( Array.isArray(layer) && layer.length > 1 && layer[0] === 'middleware::api:Permission' ) {
|
||||||
|
if ( typeof layer[1] === 'object' && layer[1].check ) {
|
||||||
|
scopes.push(layer[1].check)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scopes.sort()
|
||||||
|
return res.api(scopes.map(x => {
|
||||||
|
return { scope: x }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async check_permissions(req, res, next) {
|
||||||
|
if ( !req.body.permissions )
|
||||||
|
return res.status(400)
|
||||||
|
.message('Missing permissions to check.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const parsed = typeof req.body.permissions === 'string' ? this.utility.infer(req.body.permissions) : req.body.permissions
|
||||||
|
const permissions = Array.isArray(parsed) ? parsed : [parsed]
|
||||||
|
|
||||||
|
const returns = {}
|
||||||
|
for ( const permission of permissions ) {
|
||||||
|
returns[permission] = req.user.can(permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.api(returns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = exports = ReflectController
|
@ -4,7 +4,7 @@ const samlp = require('samlp')
|
|||||||
|
|
||||||
class SAMLController extends Controller {
|
class SAMLController extends Controller {
|
||||||
static get services() {
|
static get services() {
|
||||||
return [...super.services, 'saml', 'output', 'Vue', 'configs']
|
return [...super.services, 'saml', 'output', 'Vue', 'configs', 'models']
|
||||||
}
|
}
|
||||||
|
|
||||||
async get_metadata(req, res, next) {
|
async get_metadata(req, res, next) {
|
||||||
@ -20,10 +20,24 @@ class SAMLController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO some sort of first-logon flow
|
// TODO some sort of first-logon flow
|
||||||
// TODO Also, customize logon continue message
|
|
||||||
async get_sso(req, res, next) {
|
async get_sso(req, res, next) {
|
||||||
const index = await req.saml.participants.issue({ service_provider: req.saml_request.service_provider })
|
const index = await req.saml.participants.issue({ service_provider: req.saml_request.service_provider })
|
||||||
|
|
||||||
|
// Apply the appropriate IAM policy if this SAML SP is associated with an App
|
||||||
|
// If the SAML service provider has no associated application, just allow it
|
||||||
|
// TODO test this
|
||||||
|
const associated_app = await req.saml_request.service_provider.application()
|
||||||
|
if ( associated_app ) {
|
||||||
|
const Policy = this.models.get('iam:Policy')
|
||||||
|
const can_access = await Policy.check_user_access(req.user, associated_app.id)
|
||||||
|
if ( !can_access ) {
|
||||||
|
return this.Vue.auth_message(res, {
|
||||||
|
message: `Sorry, you don't have permission to access this application. Please ask your administrator to grant you access to ${associated_app.name}.`,
|
||||||
|
next_destination: '/dash',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return samlp.auth({
|
return samlp.auth({
|
||||||
issuer: this.saml.config().provider_name,
|
issuer: this.saml.config().provider_name,
|
||||||
cert: await this.saml.public_cert(),
|
cert: await this.saml.public_cert(),
|
||||||
|
@ -105,7 +105,6 @@ class GroupsController extends LDAPController {
|
|||||||
|
|
||||||
async get_resource_from_dn(dn) {
|
async get_resource_from_dn(dn) {
|
||||||
const cn = this.get_cn_from_dn(dn)
|
const cn = this.get_cn_from_dn(dn)
|
||||||
console.log({cn, dn})
|
|
||||||
if ( cn ) {
|
if ( cn ) {
|
||||||
return this.Group.findOne({name: cn, ldap_visible: true})
|
return this.Group.findOne({name: cn, ldap_visible: true})
|
||||||
}
|
}
|
||||||
|
29
app/models/Application.model.js
Normal file
29
app/models/Application.model.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
const { Model } = require('flitter-orm')
|
||||||
|
|
||||||
|
class ApplicationModel extends Model {
|
||||||
|
static get schema() {
|
||||||
|
return {
|
||||||
|
name: String,
|
||||||
|
identifier: String,
|
||||||
|
description: String,
|
||||||
|
active: { type: Boolean, default: true },
|
||||||
|
saml_service_provider_ids: [String],
|
||||||
|
ldap_client_ids: [String],
|
||||||
|
oauth_client_ids: [String],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async to_api() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
name: this.name,
|
||||||
|
identifier: this.identifier,
|
||||||
|
description: this.description,
|
||||||
|
saml_service_provider_ids: this.saml_service_provider_ids,
|
||||||
|
ldap_client_ids: this.ldap_client_ids,
|
||||||
|
oauth_client_ids: this.oauth_client_ids,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = exports = ApplicationModel
|
35
app/models/auth/Group.model.js
Normal file
35
app/models/auth/Group.model.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
const { Model } = require('flitter-orm')
|
||||||
|
|
||||||
|
// For organizational purposes only.
|
||||||
|
class GroupModel extends Model {
|
||||||
|
static get services() {
|
||||||
|
return [...super.services, 'models']
|
||||||
|
}
|
||||||
|
|
||||||
|
static get schema() {
|
||||||
|
return {
|
||||||
|
name: String,
|
||||||
|
user_ids: [String],
|
||||||
|
active: { type: Boolean, default: true },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
identifier() {
|
||||||
|
return this.name.toLowerCase().replace(/\s/g, '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
async users() {
|
||||||
|
const User = this.models.get('auth:User')
|
||||||
|
return await User.find({ _id: { $in: this.user_ids.map(x => this.constructor.to_object_id(x)) } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async to_api() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
name: this.name,
|
||||||
|
user_ids: this.user_ids,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = exports = GroupModel
|
@ -31,6 +31,7 @@ class User extends AuthUser {
|
|||||||
app_passwords: [AppPassword],
|
app_passwords: [AppPassword],
|
||||||
mfa_enabled: {type: Boolean, default: false},
|
mfa_enabled: {type: Boolean, default: false},
|
||||||
mfa_enable_date: Date,
|
mfa_enable_date: Date,
|
||||||
|
create_date: {type: Date, default: () => new Date},
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,6 +43,7 @@ class User extends AuthUser {
|
|||||||
last_name: this.last_name,
|
last_name: this.last_name,
|
||||||
email: this.email,
|
email: this.email,
|
||||||
tagline: this.tagline,
|
tagline: this.tagline,
|
||||||
|
group_ids: (await this.groups()).map(x => x.id),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,6 +114,11 @@ class User extends AuthUser {
|
|||||||
return { password: gen, record: pw }
|
return { password: gen, record: pw }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async groups() {
|
||||||
|
const Group = this.models.get('auth:Group')
|
||||||
|
return Group.find({ active: true, user_ids: this.id })
|
||||||
|
}
|
||||||
|
|
||||||
async ldap_groups() {
|
async ldap_groups() {
|
||||||
const Group = this.models.get('ldap:Group')
|
const Group = this.models.get('ldap:Group')
|
||||||
return await Group.find({
|
return await Group.find({
|
||||||
|
127
app/models/iam/Policy.model.js
Normal file
127
app/models/iam/Policy.model.js
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
const { Model } = require('flitter-orm')
|
||||||
|
|
||||||
|
// TODO - remove specific :create checks; auto-grant permissions on create
|
||||||
|
|
||||||
|
class PolicyModel extends Model {
|
||||||
|
static get services() {
|
||||||
|
return [...super.services, 'models']
|
||||||
|
}
|
||||||
|
|
||||||
|
static get schema() {
|
||||||
|
return {
|
||||||
|
entity_type: String, // user | group
|
||||||
|
entity_id: String,
|
||||||
|
access_type: String, // allow | deny
|
||||||
|
target_type: { type: String, default: 'application' }, // application
|
||||||
|
target_id: String,
|
||||||
|
active: { type: Boolean, default: true },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async check_allow(entity_id, target_id) {
|
||||||
|
const policies = await this.find({
|
||||||
|
entity_id,
|
||||||
|
target_id,
|
||||||
|
access_type: 'allow',
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return policies.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
static async check_deny(entity_id, target_id) {
|
||||||
|
const policies = await this.find({
|
||||||
|
entity_id,
|
||||||
|
target_id,
|
||||||
|
access_type: 'deny',
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return policies.length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
static async check_entity_access(entity_id, target_id) {
|
||||||
|
return (await this.check_allow(entity_id, target_id)) && !(await this.check_deny(entity_id, target_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
static async check_user_access(user, target_id) {
|
||||||
|
const groups = await user.groups()
|
||||||
|
const group_ids = groups.map(x => x.id)
|
||||||
|
|
||||||
|
const user_approvals = await this.find({
|
||||||
|
entity_id: user.id,
|
||||||
|
target_id,
|
||||||
|
approval_type: 'allow',
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const user_denials = await this.find({
|
||||||
|
entity_id: user.id,
|
||||||
|
target_id,
|
||||||
|
approval_type: 'deny',
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const group_approvals = await this.find({
|
||||||
|
entity_id: { $in: group_ids },
|
||||||
|
target_id,
|
||||||
|
approval_type: 'allow',
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const group_denials = await this.find({
|
||||||
|
entity_id: { $in: group_ids },
|
||||||
|
target_id,
|
||||||
|
approval_type: 'deny',
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// IF user has explicit denial, deny
|
||||||
|
if ( user_denials.length > 0 ) return false
|
||||||
|
|
||||||
|
// ELSE IF user has explicit approval, approve
|
||||||
|
if ( user_approvals.length > 0 ) return true
|
||||||
|
|
||||||
|
// ELSE IF group has denial, deny
|
||||||
|
if ( group_denials.length > 0 ) return false
|
||||||
|
|
||||||
|
// ELSE IF group has approval, approve
|
||||||
|
if ( group_approvals.length > 0 ) return true
|
||||||
|
|
||||||
|
// ELSE deny
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async to_api() {
|
||||||
|
let entity_display = ''
|
||||||
|
if ( this.entity_type === 'user' ) {
|
||||||
|
const User = this.models.get('auth:User')
|
||||||
|
const user = await User.findById(this.entity_id)
|
||||||
|
entity_display = `User: ${user.last_name}, ${user.first_name} (${user.uid})`
|
||||||
|
} else if ( this.entity_type === 'group' ) {
|
||||||
|
const Group = this.models.get('auth:Group')
|
||||||
|
const group = await Group.findById(this.entity_id)
|
||||||
|
entity_display = `Group: ${group.name} (${group.user_ids.length} users)`
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_display = ''
|
||||||
|
if ( this.target_type === 'application' ) {
|
||||||
|
const Application = this.models.get('Application')
|
||||||
|
const app = await Application.findById(this.target_id)
|
||||||
|
target_display = `Application: ${app.name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
entity_display,
|
||||||
|
entity_type: this.entity_type,
|
||||||
|
entity_id: this.entity_id,
|
||||||
|
access_type: this.access_type,
|
||||||
|
target_display,
|
||||||
|
target_type: this.target_type,
|
||||||
|
target_id: this.target_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = exports = PolicyModel
|
@ -35,15 +35,23 @@ class ClientModel extends Model {
|
|||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async invoke() {
|
||||||
|
this.last_invocation = new Date
|
||||||
|
}
|
||||||
|
|
||||||
async user() {
|
async user() {
|
||||||
const User = this.models.get('auth:User')
|
const User = this.models.get('auth:User')
|
||||||
return User.findById(this.user_id)
|
return User.findById(this.user_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async application() {
|
||||||
|
const Application = this.models.get('Application')
|
||||||
|
return Application.findOne({ active: true, ldap_client_ids: this.id })
|
||||||
|
}
|
||||||
|
|
||||||
async to_api() {
|
async to_api() {
|
||||||
const User = this.models.get('auth:User')
|
const User = this.models.get('auth:User')
|
||||||
const user = await User.findById(this.user_id)
|
const user = await User.findById(this.user_id)
|
||||||
|
|
||||||
const role_permissions = user.roles.map(x => this.configs.get('auth.roles')[x])
|
const role_permissions = user.roles.map(x => this.configs.get('auth.roles')[x])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
95
app/models/oauth/Client.model.js
Normal file
95
app/models/oauth/Client.model.js
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
const { Model } = require('flitter-orm')
|
||||||
|
const uuid = require('uuid/v4')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* OAuth2 Client Model
|
||||||
|
* ---------------------------------------------------
|
||||||
|
* Represents a single OAuth2 client. This class contains logic
|
||||||
|
* to create/update/delete the associated Flitter-Auth Oauth2Client
|
||||||
|
* instance.
|
||||||
|
*/
|
||||||
|
class ClientModel extends Model {
|
||||||
|
static get services() {
|
||||||
|
return [...super.services, 'models']
|
||||||
|
}
|
||||||
|
|
||||||
|
static get schema() {
|
||||||
|
return {
|
||||||
|
name: String,
|
||||||
|
uuid: {type: String, default: uuid},
|
||||||
|
secret: {type: String, default: uuid},
|
||||||
|
active: {type: Boolean, default: true},
|
||||||
|
api_scopes: [String],
|
||||||
|
redirect_url: String,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
can(scope) {
|
||||||
|
return this.api_scopes.includes()
|
||||||
|
}
|
||||||
|
|
||||||
|
async application() {
|
||||||
|
const Application = this.models.get('Application')
|
||||||
|
return Application.findOne({ active: true, oauth_client_ids: this.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
async update_auth_client() {
|
||||||
|
const Oauth2Client = this.models.get('auth::Oauth2Client')
|
||||||
|
let client = await Oauth2Client.findOne({ clientID: this.uuid })
|
||||||
|
|
||||||
|
// There's an associated client, but we're not active, so delete the assoc
|
||||||
|
if ( client && !this.active ) {
|
||||||
|
await client.delete()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( !client ) {
|
||||||
|
client = new Oauth2Client({
|
||||||
|
grants: ['authorization_code'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
client.clientID = this.uuid
|
||||||
|
client.clientSecret = this.secret
|
||||||
|
client.name = this.name
|
||||||
|
client.redirectUris = [this.redirect_url]
|
||||||
|
await client.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
async save() {
|
||||||
|
await super.save()
|
||||||
|
|
||||||
|
// Save the associated flitter-auth-compatible client.
|
||||||
|
await this.update_auth_client()
|
||||||
|
}
|
||||||
|
|
||||||
|
async to_api() {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
name: this.name,
|
||||||
|
uuid: this.uuid,
|
||||||
|
secret: this.secret,
|
||||||
|
api_scopes: this.api_scopes,
|
||||||
|
redirect_url: this.redirect_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// See flitter-auth/User
|
||||||
|
_array_allow_permission(array_of_permissions, permission) {
|
||||||
|
const permission_parts = permission.split(':')
|
||||||
|
|
||||||
|
for ( let i = permission_parts.length; i > 0; i-- ) {
|
||||||
|
const permission_string = permission_parts.slice(0, i).join(':')
|
||||||
|
if ( array_of_permissions.includes(permission_string) ) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// See flitter-auth/User
|
||||||
|
can(scope){
|
||||||
|
return this._array_allow_permission(this.api_scopes, scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = exports = ClientModel
|
@ -1,6 +1,10 @@
|
|||||||
const { Model } = require('flitter-orm')
|
const { Model } = require('flitter-orm')
|
||||||
|
|
||||||
class ServiceProviderModel extends Model {
|
class ServiceProviderModel extends Model {
|
||||||
|
static get services() {
|
||||||
|
return [...super.services, 'models']
|
||||||
|
}
|
||||||
|
|
||||||
static get schema() {
|
static get schema() {
|
||||||
return {
|
return {
|
||||||
name: String,
|
name: String,
|
||||||
@ -11,6 +15,11 @@ class ServiceProviderModel extends Model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async application() {
|
||||||
|
const Application = this.models.get('Application')
|
||||||
|
return Application.findOne({ active: true, saml_service_provider_ids: this.id })
|
||||||
|
}
|
||||||
|
|
||||||
to_api() {
|
to_api() {
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
|
@ -2,6 +2,16 @@ const { Middleware } = require('libflitter')
|
|||||||
|
|
||||||
class PermissionMiddleware extends Middleware {
|
class PermissionMiddleware extends Middleware {
|
||||||
async test(req, res, next, { check }) {
|
async test(req, res, next, { check }) {
|
||||||
|
// If the request was authorized using an OAuth2 bearer token,
|
||||||
|
// make sure the associated client has permission to access this endpoint.
|
||||||
|
if ( req?.oauth?.client ) {
|
||||||
|
if ( !req.oauth.client.can(check) )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Insufficient permissions (OAuth2 Client).')
|
||||||
|
.api()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the user has permission
|
||||||
if ( !req.user.can(check) )
|
if ( !req.user.can(check) )
|
||||||
return res.status(401)
|
return res.status(401)
|
||||||
.message('Insufficient permissions.')
|
.message('Insufficient permissions.')
|
||||||
|
60
app/routing/middleware/auth/APIRoute.middleware.js
Normal file
60
app/routing/middleware/auth/APIRoute.middleware.js
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
const { Middleware } = require('libflitter')
|
||||||
|
|
||||||
|
class APIRouteMiddleware extends Middleware {
|
||||||
|
static get services() {
|
||||||
|
return [...super.services, 'models']
|
||||||
|
}
|
||||||
|
|
||||||
|
async test(req, res, next, { allow_token = true, allow_user = true }) {
|
||||||
|
// First, check if there is a user in the session.
|
||||||
|
if ( allow_user && req.is_auth ) {
|
||||||
|
return next()
|
||||||
|
} else if ( allow_token ) {
|
||||||
|
return req.app.oauth2.authorise()(req, res, async e => {
|
||||||
|
if ( e ) return next(e)
|
||||||
|
// Look up the OAuth2 client an inject it into the route
|
||||||
|
if ( req.user && req.user.id ) {
|
||||||
|
const User = this.models.get('auth:User')
|
||||||
|
const user = await User.findById(req.user.id)
|
||||||
|
if ( !user )
|
||||||
|
return res.status(401)
|
||||||
|
.message('The user this token is associated with no longer exists.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
req.user = user
|
||||||
|
req.is_auth = true
|
||||||
|
|
||||||
|
// Look up the token and the associated client
|
||||||
|
const Oauth2BearerToken = this.models.get('auth::Oauth2BearerToken')
|
||||||
|
const Client = this.models.get('oauth:Client')
|
||||||
|
|
||||||
|
// e.g. "Bearer XYZ".split(' ')[1] -> "XYZ"
|
||||||
|
const bearer = req.headers.authorization.split(' ')[1]
|
||||||
|
const token = await Oauth2BearerToken.findOne({ accessToken: bearer })
|
||||||
|
if ( !token )
|
||||||
|
return res.status(401)
|
||||||
|
.message('Unable to lookup OAuth2 token.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
const client = await Client.findOne({uuid: token.clientID})
|
||||||
|
if ( !client )
|
||||||
|
return res.status(401)
|
||||||
|
.message('This OAuth2 client is no longer authorized.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
req.oauth.token = token
|
||||||
|
req.oauth.client = client
|
||||||
|
} else
|
||||||
|
return res.status(401)
|
||||||
|
.message('Unable to lookup user associated with that token.')
|
||||||
|
.api()
|
||||||
|
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(401).api()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = exports = APIRouteMiddleware
|
41
app/routing/routers/api/v1/app.routes.js
Normal file
41
app/routing/routers/api/v1/app.routes.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
const app_routes = {
|
||||||
|
prefix: '/api/v1/applications',
|
||||||
|
|
||||||
|
middleware: [
|
||||||
|
'auth:APIRoute',
|
||||||
|
],
|
||||||
|
|
||||||
|
get: {
|
||||||
|
'/': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:applications:list' }],
|
||||||
|
'controller::api:v1:App.get_applications',
|
||||||
|
],
|
||||||
|
'/:id': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:applications:get' }],
|
||||||
|
'controller::api:v1:App.get_application',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
post: {
|
||||||
|
'/': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:applications:create' }],
|
||||||
|
'controller::api:v1:App.create_application',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
patch: {
|
||||||
|
'/:id': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:applications:update' }],
|
||||||
|
'controller::api:v1:App.update_application',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: {
|
||||||
|
'/:id': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:applications:delete' }],
|
||||||
|
'controller::api:v1:App.delete_application',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = exports = app_routes
|
@ -9,30 +9,100 @@ const auth_routes = {
|
|||||||
'/mfa/enable/date': ['middleware::auth:UserOnly', 'controller::api:v1:Auth.get_mfa_enable_date'],
|
'/mfa/enable/date': ['middleware::auth:UserOnly', 'controller::api:v1:Auth.get_mfa_enable_date'],
|
||||||
|
|
||||||
'/roles': [
|
'/roles': [
|
||||||
|
'middleware::auth:APIRoute',
|
||||||
['middleware::api:Permission', { check: 'v1:auth:roles:list' }],
|
['middleware::api:Permission', { check: 'v1:auth:roles:list' }],
|
||||||
'controller::api:v1:Auth.get_roles',
|
'controller::api:v1:Auth.get_roles',
|
||||||
],
|
],
|
||||||
'/users': [
|
'/users': [
|
||||||
|
'middleware::auth:APIRoute',
|
||||||
['middleware::api:Permission', { check: 'v1:auth:users:list' }],
|
['middleware::api:Permission', { check: 'v1:auth:users:list' }],
|
||||||
'controller::api:v1:Auth.get_users',
|
'controller::api:v1:Auth.get_users',
|
||||||
],
|
],
|
||||||
|
'/groups': [
|
||||||
|
'middleware::auth:APIRoute',
|
||||||
|
['middleware::api:Permission', { check: 'v1:auth:groups:list' }],
|
||||||
|
'controller::api:v1:Auth.get_groups',
|
||||||
|
],
|
||||||
|
'/users/:id': [
|
||||||
|
'middleware::auth:APIRoute',
|
||||||
|
['middleware::api:Permission', { check: 'v1:auth:users:get' }],
|
||||||
|
'controller::api:v1:Auth.get_user',
|
||||||
|
],
|
||||||
|
'/groups/:id': [
|
||||||
|
'middleware::auth:APIRoute',
|
||||||
|
['middleware::api:Permission', { check: 'v1:auth:groups:get' }],
|
||||||
|
'controller::api:v1:Auth.get_group',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
post: {
|
post: {
|
||||||
'/validate/username': ['controller::api:v1:Auth.validate_username'],
|
'/validate/username': [
|
||||||
'/attempt': [ 'controller::api:v1:Auth.attempt' ],
|
'controller::api:v1:Auth.validate_username'
|
||||||
'/mfa/generate': ['middleware::auth:UserOnly', 'controller::api:v1:Auth.generate_mfa_key'],
|
],
|
||||||
'/mfa/attempt': ['middleware::auth:DMZOnly', 'controller::api:v1:Auth.attempt_mfa'],
|
|
||||||
|
'/attempt': [
|
||||||
|
'controller::api:v1:Auth.attempt'
|
||||||
|
],
|
||||||
|
|
||||||
|
'/mfa/generate': [
|
||||||
|
'middleware::auth:UserOnly',
|
||||||
|
'controller::api:v1:Auth.generate_mfa_key'
|
||||||
|
],
|
||||||
|
|
||||||
|
'/mfa/attempt': [
|
||||||
|
'middleware::auth:DMZOnly',
|
||||||
|
'controller::api:v1:Auth.attempt_mfa'
|
||||||
|
],
|
||||||
|
|
||||||
'/mfa/enable': [
|
'/mfa/enable': [
|
||||||
'middleware::auth:UserOnly',
|
'middleware::auth:UserOnly',
|
||||||
['middleware::auth:RequireTrust', { scope: 'mfa.enable', deplete: true }],
|
['middleware::auth:RequireTrust', { scope: 'mfa.enable', deplete: true }],
|
||||||
'controller::api:v1:Auth.enable_mfa'
|
'controller::api:v1:Auth.enable_mfa'
|
||||||
],
|
],
|
||||||
|
|
||||||
'/mfa/disable': [
|
'/mfa/disable': [
|
||||||
'middleware::auth:UserOnly',
|
'middleware::auth:UserOnly',
|
||||||
['middleware::auth:RequireTrust', { scope: 'mfa.disable', deplete: true }],
|
['middleware::auth:RequireTrust', { scope: 'mfa.disable', deplete: true }],
|
||||||
'controller::api:v1:Auth.disable_mfa',
|
'controller::api:v1:Auth.disable_mfa',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'/groups': [
|
||||||
|
'middleware::auth:APIRoute',
|
||||||
|
['middleware::api:Permission', { check: 'v1:auth:groups:create' }],
|
||||||
|
'controller::api:v1:Auth.create_group',
|
||||||
|
],
|
||||||
|
|
||||||
|
'/users': [
|
||||||
|
'middleware::auth:APIRoute',
|
||||||
|
['middleware::api:Permission', { check: 'v1:auth:users:create' }],
|
||||||
|
'controller::api:v1:Auth.create_user',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
patch: {
|
||||||
|
'/groups/:id': [
|
||||||
|
'middleware::auth:APIRoute',
|
||||||
|
['middleware::api:Permission', { check: 'v1:auth:groups:update' }],
|
||||||
|
'controller::api:v1:Auth.update_group',
|
||||||
|
],
|
||||||
|
'/users/:id': [
|
||||||
|
'middleware::auth:APIRoute',
|
||||||
|
['middleware::api:Permission', { check: 'v1:auth:users:update' }],
|
||||||
|
'controller::api:v1:Auth.update_user',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: {
|
||||||
|
'/groups/:id': [
|
||||||
|
'middleware::auth:APIRoute',
|
||||||
|
['middleware::api:Permission', { check: 'v1:auth:groups:delete' }],
|
||||||
|
'controller::api:v1:Auth.delete_group',
|
||||||
|
],
|
||||||
|
'/users/:id': [
|
||||||
|
'middleware::auth:APIRoute',
|
||||||
|
['middleware::api:Permission', { check: 'v1:auth:users:delete' }],
|
||||||
|
'controller::api:v1:Auth.delete_user',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
49
app/routing/routers/api/v1/iam.routes.js
Normal file
49
app/routing/routers/api/v1/iam.routes.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
const iam_routes = {
|
||||||
|
prefix: '/api/v1/iam',
|
||||||
|
|
||||||
|
middleware: [
|
||||||
|
'auth:APIRoute'
|
||||||
|
],
|
||||||
|
|
||||||
|
get: {
|
||||||
|
'/policy': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:iam:policy:list' }],
|
||||||
|
'controller::api:v1:IAM.get_policies',
|
||||||
|
],
|
||||||
|
'/policy/:id': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:iam:policy:get' }],
|
||||||
|
'controller::api:v1:IAM.get_policy',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
post: {
|
||||||
|
'/policy': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:iam:policy:create' }],
|
||||||
|
'controller::api:v1:IAM.create_policy',
|
||||||
|
],
|
||||||
|
'/check_entity_access': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:iam:check_entity_access' }],
|
||||||
|
'controller::api:v1:IAM.check_entity_access',
|
||||||
|
],
|
||||||
|
'/check_user_access': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:iam:check_user_access' }],
|
||||||
|
'controller::api:v1:IAM.check_user_access',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
patch: {
|
||||||
|
'/policy/:id': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:iam:policy:update' }],
|
||||||
|
'controller::api:v1:IAM.update_policy',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: {
|
||||||
|
'/policy/:id': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:iam:policy:delete' }],
|
||||||
|
'controller::api:v1:IAM.delete_policy',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = exports = iam_routes
|
@ -2,7 +2,7 @@ const ldap_routes = {
|
|||||||
prefix: '/api/v1/ldap',
|
prefix: '/api/v1/ldap',
|
||||||
|
|
||||||
middleware: [
|
middleware: [
|
||||||
'auth:UserOnly',
|
'auth:APIRoute',
|
||||||
],
|
],
|
||||||
|
|
||||||
get: {
|
get: {
|
||||||
|
@ -2,15 +2,21 @@ const message_routes = {
|
|||||||
prefix: '/api/v1/message',
|
prefix: '/api/v1/message',
|
||||||
|
|
||||||
middleware: [
|
middleware: [
|
||||||
'auth:UserOnly',
|
'auth:APIRoute',
|
||||||
],
|
],
|
||||||
|
|
||||||
get: {
|
get: {
|
||||||
'/banners': ['controller::api:v1:Message.get_banners'],
|
'/banners': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:message:banners:get' }],
|
||||||
|
'controller::api:v1:Message.get_banners',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
post: {
|
post: {
|
||||||
'/banners/read/:banner_id': ['controller::api:v1:Message.read_banner'],
|
'/banners/read/:banner_id': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:message:banners:update' }],
|
||||||
|
'controller::api:v1:Message.read_banner',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
41
app/routing/routers/api/v1/oauth.routes.js
Normal file
41
app/routing/routers/api/v1/oauth.routes.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
const oauth_routes = {
|
||||||
|
prefix: '/api/v1/oauth',
|
||||||
|
|
||||||
|
middleware: [
|
||||||
|
'auth:APIRoute',
|
||||||
|
],
|
||||||
|
|
||||||
|
get: {
|
||||||
|
'/clients': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:oauth:clients:list' }],
|
||||||
|
'controller::api:v1:OAuth.get_clients',
|
||||||
|
],
|
||||||
|
'/clients/:id': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:oauth:clients:get' }],
|
||||||
|
'controller::api:v1:OAuth.get_client',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
post: {
|
||||||
|
'/clients': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:oauth:clients:create' }],
|
||||||
|
'controller::api:v1:OAuth.create_client',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
patch: {
|
||||||
|
'/clients/:id': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:oauth:clients:update' }],
|
||||||
|
'controller::api:v1:OAuth.update_client',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: {
|
||||||
|
'/clients/:id': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:oauth:clients:delete' }],
|
||||||
|
'controller::api:v1:OAuth.delete_client',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = exports = oauth_routes
|
@ -2,16 +2,25 @@ const password_routes = {
|
|||||||
prefix: '/api/v1/password',
|
prefix: '/api/v1/password',
|
||||||
|
|
||||||
middleware: [
|
middleware: [
|
||||||
'auth:UserOnly',
|
'auth:APIRoute',
|
||||||
],
|
],
|
||||||
|
|
||||||
get: {
|
get: {
|
||||||
'/resets': ['controller::api:v1:Password.get_resets'],
|
'/resets': [
|
||||||
'/app_passwords': ['controller::api:v1:Password.get_app_passwords'],
|
['middleware::api:Permission', { check: 'v1:password:resets:get' }],
|
||||||
|
'controller::api:v1:Password.get_resets',
|
||||||
|
],
|
||||||
|
'/app_passwords': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:password:app_passwords:get' }],
|
||||||
|
'controller::api:v1:Password.get_app_passwords',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
post: {
|
post: {
|
||||||
'/app_passwords': ['controller::api:v1:Password.create_app_password'],
|
'/app_passwords': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:password:app_passwords:create' }],
|
||||||
|
'controller::api:v1:Password.create_app_password',
|
||||||
|
],
|
||||||
'/resets': [
|
'/resets': [
|
||||||
['middleware::auth:RequireTrust', { scope: 'password.reset' }],
|
['middleware::auth:RequireTrust', { scope: 'password.reset' }],
|
||||||
'controller::api:v1:Password.reset_password',
|
'controller::api:v1:Password.reset_password',
|
||||||
@ -19,7 +28,10 @@ const password_routes = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
delete: {
|
delete: {
|
||||||
'/app_passwords/:uuid': ['controller::api:v1:Password.delete_app_password'],
|
'/app_passwords/:uuid': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:password:app_passwords:delete' }],
|
||||||
|
'controller::api:v1:Password.delete_app_password',
|
||||||
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,17 +2,19 @@ const profile_routes = {
|
|||||||
prefix: '/api/v1/profile',
|
prefix: '/api/v1/profile',
|
||||||
|
|
||||||
middleware: [
|
middleware: [
|
||||||
'auth:UserOnly',
|
'auth:APIRoute',
|
||||||
],
|
],
|
||||||
|
|
||||||
get: {
|
get: {
|
||||||
'/:user_id': [ // user_id | 'me'
|
'/:user_id': [ // user_id | 'me'
|
||||||
|
['middleware::api:Permission', { check: 'v1:profile:get' }],
|
||||||
'controller::api:v1:Profile.fetch',
|
'controller::api:v1:Profile.fetch',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
patch: {
|
patch: {
|
||||||
'/:user_id': [ // user_id | 'me'
|
'/:user_id': [ // user_id | 'me'
|
||||||
|
['middleware::api:Permission', { check: 'v1:profile:update' }],
|
||||||
'controller::api:v1:Profile.update',
|
'controller::api:v1:Profile.update',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
50
app/routing/routers/api/v1/reflect.routes.js
Normal file
50
app/routing/routers/api/v1/reflect.routes.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
const reflect_routes = {
|
||||||
|
prefix: '/api/v1/reflect',
|
||||||
|
|
||||||
|
middleware: [
|
||||||
|
'auth:APIRoute'
|
||||||
|
],
|
||||||
|
|
||||||
|
get: {
|
||||||
|
'/scopes': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:reflect:scopes' }],
|
||||||
|
'controller::api:v1:Reflect.get_scopes',
|
||||||
|
],
|
||||||
|
'/tokens': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:reflect:tokens:list' }],
|
||||||
|
'controller::api:v1:Reflect.get_tokens',
|
||||||
|
],
|
||||||
|
'/tokens/:id': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:reflect:tokens:get' }],
|
||||||
|
'controller::api:v1:Reflect.get_token',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
post: {
|
||||||
|
'/tokens': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:reflect:tokens:create'}],
|
||||||
|
'controller::api:v1:Reflect.create_token',
|
||||||
|
],
|
||||||
|
|
||||||
|
'/check_permissions': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:reflect:check_permissions' }],
|
||||||
|
'controller::api:v1:Reflect.check_permissions',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
patch: {
|
||||||
|
'/tokens/:id': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:reflect:tokens:update' }],
|
||||||
|
'controller::api:v1:Reflect.update_token',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: {
|
||||||
|
'/tokens/:id': [
|
||||||
|
['middleware::api:Permission', { check: 'v1:reflect:tokens:delete' }],
|
||||||
|
'controller::api:v1:Reflect.delete_token',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = exports = reflect_routes
|
@ -2,7 +2,7 @@ const saml_routes = {
|
|||||||
prefix: '/api/v1/saml',
|
prefix: '/api/v1/saml',
|
||||||
|
|
||||||
middleware: [
|
middleware: [
|
||||||
'auth:UserOnly',
|
'auth:APIRoute',
|
||||||
],
|
],
|
||||||
|
|
||||||
get: {
|
get: {
|
||||||
|
@ -105,10 +105,12 @@ class LDAPServerUnit extends Unit {
|
|||||||
async port_free() {
|
async port_free() {
|
||||||
return new Promise((res, rej) => {
|
return new Promise((res, rej) => {
|
||||||
const server = net.createServer()
|
const server = net.createServer()
|
||||||
server.once('error', rej)
|
server.once('error', (e) => {
|
||||||
|
res(false)
|
||||||
|
})
|
||||||
server.once('listening', () => {
|
server.once('listening', () => {
|
||||||
server.close()
|
server.close()
|
||||||
res()
|
res(true)
|
||||||
})
|
})
|
||||||
server.listen(this.config.port)
|
server.listen(this.config.port)
|
||||||
})
|
})
|
||||||
|
@ -10,7 +10,7 @@ const auth_config = {
|
|||||||
servers: {
|
servers: {
|
||||||
// OAuth2 authorization server
|
// OAuth2 authorization server
|
||||||
oauth2: {
|
oauth2: {
|
||||||
enable: env('OAUTH2_SERVER_ENABLE', false),
|
enable: env('OAUTH2_SERVER_ENABLE', true),
|
||||||
|
|
||||||
// Grants that are available to clients. Supported types are authorization_code, password
|
// Grants that are available to clients. Supported types are authorization_code, password
|
||||||
grants: ['authorization_code'],
|
grants: ['authorization_code'],
|
||||||
@ -20,7 +20,8 @@ const auth_config = {
|
|||||||
|
|
||||||
// Get the token user's data
|
// Get the token user's data
|
||||||
user: {
|
user: {
|
||||||
enable: env('OAUTH2_SERVER_ENABLE', true),
|
// enable: env('OAUTH2_SERVER_ENABLE', false),
|
||||||
|
enable: false,
|
||||||
|
|
||||||
// Fields to return to the endpoint
|
// Fields to return to the endpoint
|
||||||
// The keys are the keys in the request. The values are the keys in the user.
|
// The keys are the keys in the request. The values are the keys in the user.
|
||||||
@ -177,6 +178,20 @@ const auth_config = {
|
|||||||
coreid_base: ['my:profile'],
|
coreid_base: ['my:profile'],
|
||||||
saml_admin: ['v1:saml', 'saml'],
|
saml_admin: ['v1:saml', 'saml'],
|
||||||
|
|
||||||
|
base_user: [
|
||||||
|
// Message Service
|
||||||
|
'v1:message:banners',
|
||||||
|
|
||||||
|
// Permission Checks
|
||||||
|
'v1:reflect:check_permissions',
|
||||||
|
|
||||||
|
// Profile
|
||||||
|
'v1:profile',
|
||||||
|
|
||||||
|
// Password API
|
||||||
|
'v1:password',
|
||||||
|
],
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -17,13 +17,14 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"email-validator": "^2.0.4",
|
"email-validator": "^2.0.4",
|
||||||
"flitter-auth": "^0.18.2",
|
"flitter-auth": "^0.19.0",
|
||||||
"flitter-cli": "^0.16.0",
|
"flitter-cli": "^0.16.0",
|
||||||
"flitter-di": "^0.5.0",
|
"flitter-di": "^0.5.0",
|
||||||
"flitter-flap": "^0.5.2",
|
"flitter-flap": "^0.5.2",
|
||||||
"flitter-forms": "^0.8.1",
|
"flitter-forms": "^0.8.1",
|
||||||
"flitter-less": "^0.5.3",
|
"flitter-less": "^0.5.3",
|
||||||
"flitter-upload": "^0.8.0",
|
"flitter-upload": "^0.8.0",
|
||||||
|
"is-absolute-url": "^3.0.3",
|
||||||
"ldapjs": "^1.0.2",
|
"ldapjs": "^1.0.2",
|
||||||
"libflitter": "^0.51.0",
|
"libflitter": "^0.51.0",
|
||||||
"moment": "^2.24.0",
|
"moment": "^2.24.0",
|
||||||
|
20
yarn.lock
20
yarn.lock
@ -1779,10 +1779,10 @@ flat@^4.1.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-buffer "~2.0.3"
|
is-buffer "~2.0.3"
|
||||||
|
|
||||||
flitter-auth@^0.18.2:
|
flitter-auth@^0.19.0:
|
||||||
version "0.18.2"
|
version "0.19.0"
|
||||||
resolved "https://registry.yarnpkg.com/flitter-auth/-/flitter-auth-0.18.2.tgz#25f6d3311168f85989ff78ebacdfe7a6ace13c3f"
|
resolved "https://registry.yarnpkg.com/flitter-auth/-/flitter-auth-0.19.0.tgz#fa6d0b44c8edfed45c1f0d7de65b9acb8d444c19"
|
||||||
integrity sha512-kJGHf0zOo8ICerVt8jgyDiaDrJ+Ob3KVh9wpwpDo6aI37U26bTYfSUrJdU6ge0rLwnTTbInXVpndEjd465bQAw==
|
integrity sha512-WoNkIGG981Zy3L0qqvml0rpxwNyfVAfAXjvZE6i6XnDJeLdsqHxCAVPllJlOhfJmuFPCr2TGXPl8WhAQaoG6Bw==
|
||||||
dependencies:
|
dependencies:
|
||||||
axios "^0.19.0"
|
axios "^0.19.0"
|
||||||
bcrypt "^3.0.4"
|
bcrypt "^3.0.4"
|
||||||
@ -2299,6 +2299,11 @@ ipaddr.js@1.8.0:
|
|||||||
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.8.0.tgz#eaa33d6ddd7ace8f7f6fe0c9ca0440e706738b1e"
|
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.8.0.tgz#eaa33d6ddd7ace8f7f6fe0c9ca0440e706738b1e"
|
||||||
integrity sha1-6qM9bd16zo9/b+DJygRA5wZzix4=
|
integrity sha1-6qM9bd16zo9/b+DJygRA5wZzix4=
|
||||||
|
|
||||||
|
is-absolute-url@^3.0.3:
|
||||||
|
version "3.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-3.0.3.tgz#96c6a22b6a23929b11ea0afb1836c36ad4a5d698"
|
||||||
|
integrity sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q==
|
||||||
|
|
||||||
is-binary-path@~2.1.0:
|
is-binary-path@~2.1.0:
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
|
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
|
||||||
@ -4909,12 +4914,7 @@ xmldom@=0.1.15:
|
|||||||
resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.15.tgz#b3048062f1bdd52edc421424459f06dceeb2f94d"
|
resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.15.tgz#b3048062f1bdd52edc421424459f06dceeb2f94d"
|
||||||
integrity sha1-swSAYvG91S7cQhQkRZ8G3O6y+U0=
|
integrity sha1-swSAYvG91S7cQhQkRZ8G3O6y+U0=
|
||||||
|
|
||||||
xmldom@=0.1.19:
|
xmldom@=0.1.19, xmldom@auth0/xmldom#v0.1.19-auth0_1:
|
||||||
version "0.1.19"
|
|
||||||
resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.19.tgz#631fc07776efd84118bf25171b37ed4d075a0abc"
|
|
||||||
integrity sha1-Yx/Ad3bv2EEYvyUXGzftTQdaCrw=
|
|
||||||
|
|
||||||
xmldom@auth0/xmldom#v0.1.19-auth0_1:
|
|
||||||
version "0.1.19"
|
version "0.1.19"
|
||||||
resolved "https://codeload.github.com/auth0/xmldom/tar.gz/3376bc7beb5551bf68e12b0cc6b0e3669f77d392"
|
resolved "https://codeload.github.com/auth0/xmldom/tar.gz/3376bc7beb5551bf68e12b0cc6b0e3669f77d392"
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user