Introducing the Setup Wizard for creating the first user

- no longer setup a default
- still able to do that with env vars however
This commit is contained in:
Jamie Curnow
2025-09-08 19:47:00 +10:00
parent 432afe73ad
commit fa11945235
31 changed files with 867 additions and 660 deletions

View File

@@ -22,13 +22,13 @@ import errs from "./error.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
export default function (token_string) {
export default function (tokenString) {
const Token = TokenModel();
let token_data = null;
let tokenData = null;
let initialised = false;
const object_cache = {};
let allow_internal_access = false;
let user_roles = [];
const objectCache = {};
let allowInternalAccess = false;
let userRoles = [];
let permissions = {};
/**
@@ -36,65 +36,58 @@ export default function (token_string) {
*
* @returns {Promise}
*/
this.init = () => {
return new Promise((resolve, reject) => {
if (initialised) {
resolve();
} else if (!token_string) {
reject(new errs.PermissionError("Permission Denied"));
this.init = async () => {
if (initialised) {
return;
}
if (!tokenString) {
throw new errs.PermissionError("Permission Denied");
}
tokenData = await Token.load(tokenString);
// At this point we need to load the user from the DB and make sure they:
// - exist (and not soft deleted)
// - still have the appropriate scopes for this token
// This is only required when the User ID is supplied or if the token scope has `user`
if (
tokenData.attrs.id ||
(typeof tokenData.scope !== "undefined" && _.indexOf(tokenData.scope, "user") !== -1)
) {
// Has token user id or token user scope
const user = await userModel
.query()
.where("id", tokenData.attrs.id)
.andWhere("is_deleted", 0)
.andWhere("is_disabled", 0)
.allowGraph("[permissions]")
.withGraphFetched("[permissions]")
.first();
if (user) {
// make sure user has all scopes of the token
// The `user` role is not added against the user row, so we have to just add it here to get past this check.
user.roles.push("user");
let ok = true;
_.forEach(tokenData.scope, (scope_item) => {
if (_.indexOf(user.roles, scope_item) === -1) {
ok = false;
}
});
if (!ok) {
throw new errs.AuthError("Invalid token scope for User");
}
initialised = true;
userRoles = user.roles;
permissions = user.permissions;
} else {
resolve(
Token.load(token_string).then((data) => {
token_data = data;
// At this point we need to load the user from the DB and make sure they:
// - exist (and not soft deleted)
// - still have the appropriate scopes for this token
// This is only required when the User ID is supplied or if the token scope has `user`
if (
token_data.attrs.id ||
(typeof token_data.scope !== "undefined" &&
_.indexOf(token_data.scope, "user") !== -1)
) {
// Has token user id or token user scope
return userModel
.query()
.where("id", token_data.attrs.id)
.andWhere("is_deleted", 0)
.andWhere("is_disabled", 0)
.allowGraph("[permissions]")
.withGraphFetched("[permissions]")
.first()
.then((user) => {
if (user) {
// make sure user has all scopes of the token
// The `user` role is not added against the user row, so we have to just add it here to get past this check.
user.roles.push("user");
let is_ok = true;
_.forEach(token_data.scope, (scope_item) => {
if (_.indexOf(user.roles, scope_item) === -1) {
is_ok = false;
}
});
if (!is_ok) {
throw new errs.AuthError("Invalid token scope for User");
}
initialised = true;
user_roles = user.roles;
permissions = user.permissions;
} else {
throw new errs.AuthError("User cannot be loaded for Token");
}
});
}
initialised = true;
}),
);
throw new errs.AuthError("User cannot be loaded for Token");
}
});
}
initialised = true;
};
/**
@@ -102,82 +95,64 @@ export default function (token_string) {
* This only applies to USER token scopes, as all other tokens are not really bound
* by object scopes
*
* @param {String} object_type
* @param {String} objectType
* @returns {Promise}
*/
this.loadObjects = (object_type) => {
return new Promise((resolve, reject) => {
if (Token.hasScope("user")) {
if (
typeof token_data.attrs.id === "undefined" ||
!token_data.attrs.id
) {
reject(new errs.AuthError("User Token supplied without a User ID"));
} else {
const token_user_id = token_data.attrs.id ? token_data.attrs.id : 0;
let query;
this.loadObjects = async (objectType) => {
let objects = null;
if (typeof object_cache[object_type] === "undefined") {
switch (object_type) {
// USERS - should only return yourself
case "users":
resolve(token_user_id ? [token_user_id] : []);
break;
if (Token.hasScope("user")) {
if (typeof tokenData.attrs.id === "undefined" || !tokenData.attrs.id) {
throw new errs.AuthError("User Token supplied without a User ID");
}
// Proxy Hosts
case "proxy_hosts":
query = proxyHostModel
.query()
.select("id")
.andWhere("is_deleted", 0);
const tokenUserId = tokenData.attrs.id ? tokenData.attrs.id : 0;
let query;
if (permissions.visibility === "user") {
query.andWhere("owner_user_id", token_user_id);
}
if (typeof objectCache[objectType] !== "undefined") {
objects = objectCache[objectType];
} else {
switch (objectType) {
// USERS - should only return yourself
case "users":
objects = tokenUserId ? [tokenUserId] : [];
break;
resolve(
query.then((rows) => {
const result = [];
_.forEach(rows, (rule_row) => {
result.push(rule_row.id);
});
// enum should not have less than 1 item
if (!result.length) {
result.push(0);
}
return result;
}),
);
break;
// DEFAULT: null
default:
resolve(null);
break;
// Proxy Hosts
case "proxy_hosts": {
query = proxyHostModel.query().select("id").andWhere("is_deleted", 0);
if (permissions.visibility === "user") {
query.andWhere("owner_user_id", tokenUserId);
}
} else {
resolve(object_cache[object_type]);
const rows = await query();
objects = [];
_.forEach(rows, (ruleRow) => {
result.push(ruleRow.id);
});
// enum should not have less than 1 item
if (!objects.length) {
objects.push(0);
}
break;
}
}
} else {
resolve(null);
objectCache[objectType] = objects;
}
}).then((objects) => {
object_cache[object_type] = objects;
return objects;
});
}
return objects;
};
/**
* Creates a schema object on the fly with the IDs and other values required to be checked against the permissionSchema
*
* @param {String} permission_label
* @param {String} permissionLabel
* @returns {Object}
*/
this.getObjectSchema = (permission_label) => {
const base_object_type = permission_label.split(":").shift();
this.getObjectSchema = async (permissionLabel) => {
const baseObjectType = permissionLabel.split(":").shift();
const schema = {
$id: "objects",
@@ -200,41 +175,39 @@ export default function (token_string) {
},
};
return this.loadObjects(base_object_type).then((object_result) => {
if (typeof object_result === "object" && object_result !== null) {
schema.properties[base_object_type] = {
type: "number",
enum: object_result,
minimum: 1,
};
} else {
schema.properties[base_object_type] = {
type: "number",
minimum: 1,
};
}
const result = await this.loadObjects(baseObjectType);
if (typeof result === "object" && result !== null) {
schema.properties[baseObjectType] = {
type: "number",
enum: result,
minimum: 1,
};
} else {
schema.properties[baseObjectType] = {
type: "number",
minimum: 1,
};
}
return schema;
});
return schema;
};
// here:
return {
token: Token,
/**
*
* @param {Boolean} [allow_internal]
* @param {Boolean} [allowInternal]
* @returns {Promise}
*/
load: (allow_internal) => {
return new Promise((resolve /*, reject*/) => {
if (token_string) {
resolve(Token.load(token_string));
} else {
allow_internal_access = allow_internal;
resolve(allow_internal_access || null);
}
});
load: async (allowInternal) => {
if (tokenString) {
return await Token.load(tokenString);
}
allowInternalAccess = allowInternal;
return allowInternal || null;
},
reloadObjects: this.loadObjects,
@@ -246,7 +219,7 @@ export default function (token_string) {
* @returns {Promise}
*/
can: async (permission, data) => {
if (allow_internal_access === true) {
if (allowInternalAccess === true) {
return true;
}
@@ -258,7 +231,7 @@ export default function (token_string) {
[permission]: {
data: data,
scope: Token.get("scope"),
roles: user_roles,
roles: userRoles,
permission_visibility: permissions.visibility,
permission_proxy_hosts: permissions.proxy_hosts,
permission_redirection_hosts: permissions.redirection_hosts,
@@ -277,10 +250,9 @@ export default function (token_string) {
properties: {},
};
const rawData = fs.readFileSync(
`${__dirname}/access/${permission.replace(/:/gim, "-")}.json`,
{ encoding: "utf8" },
);
const rawData = fs.readFileSync(`${__dirname}/access/${permission.replace(/:/gim, "-")}.json`, {
encoding: "utf8",
});
permissionSchema.properties[permission] = JSON.parse(rawData);
const ajv = new Ajv({

View File

@@ -1,15 +1,15 @@
import Access from "../access.js";
export default () => {
return (_, res, next) => {
res.locals.access = null;
const access = new Access(res.locals.token || null);
access
.load()
.then(() => {
res.locals.access = access;
next();
})
.catch(next);
return async (_, res, next) => {
try {
res.locals.access = null;
const access = new Access(res.locals.token || null);
await access.load();
res.locals.access = access;
next();
} catch (err) {
next(err);
}
};
};

View File

@@ -14,30 +14,27 @@ const ajv = new Ajv({
* @param {Object} payload
* @returns {Promise}
*/
function apiValidator(schema, payload /*, description*/) {
return new Promise(function Promise_apiValidator(resolve, reject) {
if (schema === null) {
reject(new errs.ValidationError("Schema is undefined"));
return;
}
const apiValidator = async (schema, payload /*, description*/) => {
if (!schema) {
throw new errs.ValidationError("Schema is undefined");
}
if (typeof payload === "undefined") {
reject(new errs.ValidationError("Payload is undefined"));
return;
}
// Can't use falsy check here as valid payload could be `0` or `false`
if (typeof payload === "undefined") {
throw new errs.ValidationError("Payload is undefined");
}
const validate = ajv.compile(schema);
const valid = validate(payload);
const validate = ajv.compile(schema);
const valid = validate(payload);
if (valid && !validate.errors) {
resolve(payload);
} else {
const message = ajv.errorsText(validate.errors);
const err = new errs.ValidationError(message);
err.debug = [validate.errors, payload];
reject(err);
}
});
}
if (valid && !validate.errors) {
return payload;
}
const message = ajv.errorsText(validate.errors);
const err = new errs.ValidationError(message);
err.debug = [validate.errors, payload];
throw err;
};
export default apiValidator;