Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • xeruf/dashboard
  • stackspin/dashboard
2 results
Show changes
Showing
with 1048 additions and 195 deletions
# from config import KRATOS_ADMIN_URL
# from database import db
# from kubernetes.client import CustomObjectsApi
# import re
import requests
from requests.exceptions import ConnectionError
from flask import current_app
# import helpers.kubernetes
class ResourcesService:
@classmethod
def get_resources(cls):
# custom = CustomObjectsApi()
# raw_nodes = custom.list_cluster_custom_object('metrics.k8s.io', 'v1beta1', 'nodes')
# raw_pods = custom.list_cluster_custom_object('metrics.k8s.io', 'v1beta1', 'pods')
# nodes = []
# for node in raw_nodes["items"]:
# nodes.append({
# "name": node["metadata"]["name"],
# "cpu_raw": node["usage"]["cpu"],
# "cpu": cls.parse_cpu(node["usage"]["cpu"]),
# "memory_raw": node["usage"]["memory"],
# "memory_used": cls.parse_memory(node["usage"]["memory"]),
# })
try:
cores = cls.get_prometheus('machine_cpu_cores')
current_app.logger.info(f"Number of cores: {cores}")
result = {
"success": True,
# Number of cores in use. So average usage times number of cores.
"cpu": cores * cls.get_prometheus('1 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[8m])))', 'float'),
"cpu_total": cores,
# "memory_raw": node["usage"]["memory"],
# "memory_used": cls.parse_memory(node["usage"]["memory"]),
"memory_total": cls.get_prometheus('machine_memory_bytes'),
"memory_available": cls.get_prometheus('node_memory_MemAvailable_bytes'),
"disk_free": cls.get_prometheus('node_filesystem_free_bytes{mountpoint="/"}'),
"disk_total": cls.get_prometheus('node_filesystem_size_bytes{mountpoint="/"}'),
}
except ConnectionError:
return {
"success": False,
"message": "Could not contact prometheus; perhaps monitoring is not enabled.",
}
return result
# @staticmethod
# def parse_cpu(s):
# result = re.match(r"^(\d+)([mun]?)$", s)
# if result is None:
# raise Exception("cpu data does not match known patterns")
# number = result.group(1)
# suffix = result.group(2)
# multipliers = {"": 1, "m": 1e-3, "u": 1e-6, "n": 1e-9}
# return (int(number) * multipliers[suffix])
# @staticmethod
# def parse_memory(s):
# result = re.match(r"^(\d+)(|Ki|Mi|Gi)$", s)
# if result is None:
# raise Exception("memory data does not match known patterns")
# number = result.group(1)
# suffix = result.group(2)
# multipliers = {"": 1, "Ki": 1024, "Mi": 1024*1024, "Gi": 1024*1024*1024}
# return (int(number) * multipliers[suffix])
@staticmethod
def get_prometheus(query, cast='int'):
try:
params = {
"query": query,
}
result = requests.get("http://kube-prometheus-stack-prometheus:9090/api/v1/query", params=params)
current_app.logger.info(query)
current_app.logger.info(result.json())
value = result.json()["data"]["result"][0]["value"][1]
except AttributeError:
return None
if cast == 'float':
converted = float(value)
else:
converted = int(value)
return converted
# "pods": {
# "apiVersion": "metrics.k8s.io/v1beta1",
# "items": [
# {
# "containers": [
# {
# "name": "traffic-manager",
# "usage": {
# "cpu": "2839360n",
# "memory": "24696Ki"
# }
# }
# ],
# "metadata": {
# "creationTimestamp": "2023-11-30T15:10:10Z",
# "labels": {
# "app": "traffic-manager",
# "pod-template-hash": "5cd7cc7fd6",
# "telepresence": "manager"
# },
# "name": "traffic-manager-5cd7cc7fd6-mp7td",
# "namespace": "ambassador"
# },
# "timestamp": "2023-11-30T15:10:00Z",
# "window": "12.942s"
# },
......@@ -3,10 +3,11 @@ from database import db
class Role(db.Model):
ADMIN_ROLE_ID = 1
NO_ACCESS_ROLE_ID = 3
id = db.Column(Integer, primary_key=True)
name = db.Column(String(length=64))
def __repr__(self):
def __str__(self):
return f"Role {self.name}"
from areas.apps.models import AppRole
from areas.apps.models import App, AppRole
from .models import Role
......@@ -14,5 +14,6 @@ class RoleService:
@staticmethod
def is_user_admin(userId):
dashboard_role_id = AppRole.query.filter_by(user_id=userId, app_id=1).first().role_id
return dashboard_role_id == 1
\ No newline at end of file
dashboard_app_id = App.query.filter_by(slug='dashboard').first().id
dashboard_role_id = AppRole.query.filter_by(user_id=userId, app_id=dashboard_app_id).first().role_id
return dashboard_role_id == 1
from .apps import *
from .apps_service import *
from .models import *
from .tags import *
from sqlalchemy import Integer, String
from database import db
class Tag(db.Model):
id = db.Column(Integer, primary_key=True)
name = db.Column(String(length=256))
colour = db.Column(String(length=64))
def __str__(self):
return f"Tag {self.slug}"
class TagUser(db.Model):
__tablename__ = "tag_user"
user_id = db.Column(String(length=64), primary_key=True)
tag_id = db.Column(Integer, primary_key=True)
def __str__(self):
return f"TagUser, with tag_id {self.tag_id}, user_id {self.user_id}"
from database import db
from .models import Tag
class TagService:
@staticmethod
def get_tags():
tags = Tag.query.all()
return [{"id": t.id, "name": t.name, "colour": t.colour} for t in tags]
@staticmethod
def create_tag(data):
tag = Tag(name=data["name"], colour=data.get("colour"))
db.session.add(tag)
db.session.commit()
return {"id": tag.id, "name": tag.name, "colour": tag.colour}
@staticmethod
def update_tag(id, data):
tag = Tag.query.get(id)
for key, value in data.items():
if hasattr(tag, key):
setattr(tag, key, value)
db.session.commit()
@staticmethod
def delete_tag(id):
tag = Tag.query.get(id)
db.session.delete(tag)
db.session.commit()
return {"message": "Tag deleted successfully."}
from flask import jsonify, request
from flask_cors import cross_origin
from flask_expects_json import expects_json
from flask_jwt_extended import jwt_required
from areas import api_v1
from helpers.auth_guard import admin_required
from .tag_service import TagService
from .validation import schema
@api_v1.route("/tags", methods=["GET"])
@jwt_required()
@cross_origin()
@admin_required()
def get_tags():
result = TagService.get_tags()
return jsonify(result)
@api_v1.route("/tags", methods=["POST"])
@jwt_required()
@cross_origin()
@expects_json(schema)
@admin_required()
def post_tag():
data = request.get_json()
tag = TagService.create_tag(data)
return jsonify(tag)
@api_v1.route("/tags/<int:id>", methods=["PUT"])
@jwt_required()
@cross_origin()
@expects_json(schema)
@admin_required()
def put_tag(id):
data = request.get_json()
result = TagService.update_tag(id, data)
return jsonify(message="Tag updated successfully.")
@api_v1.route("/tags/<int:id>", methods=["DELETE"])
@jwt_required()
@cross_origin()
@admin_required()
def delete_tag(id):
TagService.delete_tag(id)
return jsonify(message="Tag deleted successfully.")
import re
schema = {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name describing the tag.",
"minLength": 1,
},
"colour": {
"type": "string",
"description": "Colour that may be used when displaying the tag.",
},
},
"required": ["name"],
}
from areas.apps.models import App, AppRole
from areas.roles.models import Role
from areas.tags.models import TagUser
from database import db
from helpers import KratosApi
class User():
@staticmethod
def get_all():
page = 0
userList = []
# Get all associated user data (Stackspin roles, tags).
stackspinData = UserStackspinData()
while page >= 0:
if page == 0:
res = KratosApi.get("/admin/identities?per_page=1000").json()
else:
res = KratosApi.get("/admin/identities?per_page=1000&page={}".format(page)).json()
for r in res:
# Inject information from the `stackspin` database that's associated to this user.
r["stackspin_data"] = stackspinData.getData(r["id"])
userList.append(r)
if len(res) == 0:
page = -1
else:
page = page + 1
return userList
class UserStackspinData():
# TODO: we currently ignore the userID parameter, so we always get all
# associated information even if we only need it for a single user.
# That should be changed.
def __init__(self, userID=None):
self.dashboardRoles = self.__getDashboardRoles()
self.userTags = self.__getUserTags()
def getData(self, userID):
stackspinData = {}
dashboardRole = self.dashboardRoles.get(userID)
if dashboardRole is not None:
stackspinData["stackspin_admin"] = dashboardRole == Role.ADMIN_ROLE_ID
# Also, user tags.
stackspinData["tags"] = self.userTags.get(userID, [])
return stackspinData
@staticmethod
def setTags(userID, tags):
# Delete all existing tags, because the new set of tags is interpreted
# to overwrite the previous set.
db.session.query(TagUser).filter(TagUser.user_id == userID).delete()
# Now create an entry for every tag in the new list.
for tagID in tags:
tagUser = TagUser(user_id=userID, tag_id=tagID)
db.session.add(tagUser)
@staticmethod
def __getDashboardRoles():
dashboardRoles = {}
for appRole, app in (
db.session.query(AppRole, App)
.filter(AppRole.app_id == App.id)
.filter(App.slug == "dashboard")
.all()
):
dashboardRoles[appRole.user_id] = appRole.role_id
return dashboardRoles
@staticmethod
def __getUserTags():
userTags = {}
for tagUser in db.session.query(TagUser).all():
if tagUser.user_id in userTags:
userTags[tagUser.user_id].append(tagUser.tag_id)
else:
userTags[tagUser.user_id] = [tagUser.tag_id]
return userTags
import ory_kratos_client
from ory_kratos_client.model.submit_self_service_recovery_flow_body \
import SubmitSelfServiceRecoveryFlowBody
from ory_kratos_client.api import v0alpha2_api as kratos_api
from config import KRATOS_ADMIN_URL
from ory_kratos_client.models.json_patch \
import JsonPatch
from ory_kratos_client.models.update_recovery_flow_body \
import UpdateRecoveryFlowBody
from ory_kratos_client.models.update_recovery_flow_with_link_method \
import UpdateRecoveryFlowWithLinkMethod
from ory_kratos_client.api import frontend_api, identity_api
from database import db
from areas.apps import App, AppRole, AppsService
from areas.roles import Role, RoleService
from helpers import KratosApi
from datetime import datetime
import time
from flask import current_app
from .models import User, UserStackspinData
from config import KRATOS_ADMIN_URL
from database import db
from areas.apps.models import App, AppRole, ProvisionStatus
from areas.apps.apps_service import AppsService
from areas.roles import Role
from helpers import KratosApi
from helpers.error_handler import KratosError
from helpers.provision import Provision
from helpers.threads import request_provision
kratos_admin_api_configuration = \
ory_kratos_client.Configuration(host=KRATOS_ADMIN_URL, discard_unknown_keys=True)
KRATOS_ADMIN = \
kratos_api.V0alpha2Api(ory_kratos_client.ApiClient(kratos_admin_api_configuration))
class UserService:
@staticmethod
def get_users():
res = KratosApi.get("/admin/identities").json()
userList = []
for r in res:
userList.append(UserService.__insertAppRoleToUser(r["id"], r))
kratos_admin_api_configuration = ory_kratos_client.Configuration(host=KRATOS_ADMIN_URL)
kratos_client = ory_kratos_client.ApiClient(kratos_admin_api_configuration)
kratos_frontend_api = frontend_api.FrontendApi(kratos_client)
kratos_identity_api = identity_api.IdentityApi(kratos_client)
return userList
class UserService:
@classmethod
def get_users(cls):
return User.get_all()
@staticmethod
def get_user(id):
@classmethod
def get_user(cls, id):
res = KratosApi.get("/admin/identities/{}".format(id)).json()
return UserService.__insertAppRoleToUser(id, res)
res["stackspin_data"] = UserStackspinData(id).getData(id)
# TODO: this adds app roles to the kratos `traits` field, but I think
# that belongs under `stackspin_data`.
return cls.__insertAppRoleToUser(id, res)
@staticmethod
def post_user(data):
def create_recovery_link(id):
kratos_data = {
"identity_id": id
}
res = KratosApi.post("/admin/recovery/link", kratos_data).json()
return res
@classmethod
def post_user(cls, data):
kratos_data = {
"schema_id": "default",
"traits": {
......@@ -55,7 +72,6 @@ class UserService:
)
db.session.add(app_role)
db.session.commit()
else:
all_apps = AppsService.get_all_apps()
for app in all_apps:
......@@ -66,12 +82,28 @@ class UserService:
)
db.session.add(app_role)
db.session.commit()
UserService.__start_recovery_flow(data["email"])
if data.get("tags") is not None:
UserStackspinData.setTags(res["id"], data["tags"])
# Commit all changes to the stackspin database.
db.session.commit()
request_provision()
# We start a recovery flow immediately after creating the
# user, so the user can set their initial password.
cls.__start_recovery_flow(data["email"])
return UserService.get_user(res["id"])
@staticmethod
def reset_totp(id):
KratosApi.delete("/admin/identities/{}/credentials/totp".format(id))
@staticmethod
def reset_webauthn(id):
KratosApi.delete("/admin/identities/{}/credentials/webauthn".format(id))
@staticmethod
def __start_recovery_flow(email):
......@@ -85,55 +117,83 @@ class UserService:
:param email: Email to send recovery link to
:type email: str
"""
api_response = KRATOS_ADMIN.initialize_self_service_recovery_flow_without_browser()
flow = api_response['id']
api_response = kratos_frontend_api.create_native_recovery_flow()
flow = api_response.id
# Submit the recovery flow to send an email to the new user.
submit_self_service_recovery_flow_body = \
SubmitSelfServiceRecoveryFlowBody(method="link", email=email)
api_response = KRATOS_ADMIN.submit_self_service_recovery_flow(flow,
submit_self_service_recovery_flow_body=
submit_self_service_recovery_flow_body)
update_recovery_flow_body = \
UpdateRecoveryFlowBody(UpdateRecoveryFlowWithLinkMethod(method="link", email=email))
api_response = kratos_frontend_api.update_recovery_flow(flow,
update_recovery_flow_body=update_recovery_flow_body)
@staticmethod
def put_user(id, user_editing_id, data):
kratos_data = {
"schema_id": "default",
"traits": {"email": data["email"], "name": data["name"]},
}
KratosApi.put("/admin/identities/{}".format(id), kratos_data)
@classmethod
def put_user(cls, id, data):
# Get the old version of the identity. We need that for comparison to
# see if some attributes are changed by our update.
old_user = KratosApi.get("/admin/identities/{}".format(id)).json()
old_name = old_user["traits"].get("name", "")
new_name = data.get("name", "")
# Create list of patches with our changes.
patches = []
patches.append(JsonPatch(op="replace", path="/traits/email", value=data['email']))
patches.append(JsonPatch(op="replace", path="/traits/name", value=new_name))
# Determine whether we're really changing the name, and if so record
# that fact in the database in the form of a ScimAttribute. We'll use
# that information later during provisioning via SCIM.
if old_name != new_name:
current_app.logger.info(f"Name changed for: {data['email']}")
current_app.logger.info(f" old name: {old_name}")
current_app.logger.info(f" new name: {new_name}")
Provision.store_attribute(attribute='name', user_id=id)
# We used a PUT before, but that deletes any attributes that we don't
# specify, which is not so convenient. So we PATCH just the attributes
# we're changing instead.
kratos_identity_api.patch_identity(id, json_patch=patches)
is_admin = RoleService.is_user_admin(user_editing_id)
if is_admin and data["app_roles"]:
if data["app_roles"]:
app_roles = data["app_roles"]
for ar in app_roles:
app = App.query.filter_by(slug=ar["name"]).first()
app_role = AppRole.query.filter_by(
user_id=id, app_id=app.id).first()
if app_role:
app_role.role_id = ar["role_id"] if "role_id" in ar else None
db.session.commit()
else:
appRole = AppRole(
user_id=id,
role_id=ar["role_id"] if "role_id" in ar else None,
app_id=app.id,
)
db.session.add(appRole)
db.session.commit()
cls.set_user_role(id, app.id, ar["role_id"] if "role_id" in ar else None)
if data.get("tags") is not None:
UserStackspinData.setTags(id, data["tags"])
db.session.commit()
request_provision()
return cls.get_user(id)
return UserService.get_user(id)
@classmethod
def set_user_role(cls, user_id, app_id, role_id):
app_role = AppRole.query.filter_by(user_id=user_id, app_id=app_id).first()
if app_role:
# There is already a role set for this user and app, so we
# edit it.
app_role.role_id = role_id
# Mark the app role so the SCIM routine will pick it up at
# its next run.
app_role.provision_status = ProvisionStatus.SyncNeeded
else:
# There is no role set yet for this user and app, so we
# create a new one.
appRole = AppRole(
user_id=user_id,
role_id=role_id,
app_id=app_id,
)
db.session.add(appRole)
request_provision()
@staticmethod
def delete_user(id):
app_role = AppRole.query.filter_by(user_id=id).all()
for ar in app_role:
db.session.delete(ar)
ar.provision_status = ProvisionStatus.ToDelete
db.session.commit()
request_provision()
@staticmethod
def post_multiple_users(data):
@classmethod
def post_multiple_users(cls, data):
# check if data is array
# for every item in array call Kratos
created_users = []
......@@ -145,7 +205,7 @@ class UserService:
if not user_email:
return
try:
UserService.post_user(user_data)
cls.post_user(user_data)
current_app.logger.info(f"Batch create user: {user_email}")
created_users.append(user_email)
except KratosError as err:
......@@ -161,6 +221,7 @@ class UserService:
f"Exception: {error} on creating user: {user_email}")
creation_failed_users.append(user_email)
request_provision()
success_response = {}
existing_response = {}
failed_response = {}
......@@ -176,20 +237,40 @@ class UserService:
return {"success": success_response, "existing": existing_response, "failed": failed_response}
@staticmethod
def recovery_complete(userID):
# Current unix time.
now = int(datetime.today().timestamp())
current_app.logger.info(f"Set last_recovery for {userID} to {now}")
patch = JsonPatch(op="replace", path="/metadata_admin/last_recovery", value=now)
kratos_identity_api.patch_identity(userID, json_patch=[patch])
@staticmethod
def login_complete(userID):
# Current unix time.
now = int(datetime.today().timestamp())
current_app.logger.info(f"Set last_login for {userID} to {now}")
patch = JsonPatch(op="replace", path="/metadata_admin/last_login", value=now)
kratos_identity_api.patch_identity(userID, json_patch=[patch])
@staticmethod
def __insertAppRoleToUser(userId, userRes):
apps = App.query.all()
app_roles = []
for app in apps:
tmp_app_role = AppRole.query.filter_by(
user_id=userId, app_id=app.id
).first()
app_roles.append(
{
"name": app.slug,
"role_id": tmp_app_role.role_id if tmp_app_role else None,
}
)
# Only show role when installed
app_status = app.get_status()
if app_status.installed:
tmp_app_role = AppRole.query.filter_by(
user_id=userId, app_id=app.id
).first()
app_roles.append(
{
"name": app.slug,
"role_id": tmp_app_role.role_id if tmp_app_role else None,
}
)
userRes["traits"]["app_roles"] = app_roles
return userRes
from flask import jsonify, request
from flask_jwt_extended import get_jwt, jwt_required
from flask_cors import cross_origin
from flask_expects_json import expects_json
from flask_jwt_extended import get_jwt, jwt_required
from areas import api_v1
from helpers import KratosApi
from helpers.auth_guard import admin_required
from helpers.auth_guard import admin_required, kratos_webhook
from .validation import schema, schema_multiple
from .validation import schema, schema_multiple, schema_multi_edit, schema_recovery_complete
from .user_service import UserService
......@@ -28,6 +28,57 @@ def get_user(id):
res = UserService.get_user(id)
return jsonify(res)
@api_v1.route("/users/<string:id>/recovery", methods=["POST"])
@jwt_required()
@cross_origin()
@admin_required()
def get_user_recovery(id):
res = UserService.create_recovery_link(id)
return jsonify(res)
@api_v1.route("/users/<string:id>/reset_totp", methods=["POST"])
@jwt_required()
@cross_origin()
@admin_required()
def reset_totp(id):
res = UserService.reset_totp(id)
return jsonify(res)
@api_v1.route("/users/<string:id>/reset_webauthn", methods=["POST"])
@jwt_required()
@cross_origin()
@admin_required()
def reset_webauthn(id):
res = UserService.reset_webauthn(id)
return jsonify(res)
# This is supposed to be called by Kratos as a webhook after a user has
# successfully recovered their account.
@api_v1.route("/users/recovery_complete", methods=["POST"])
@expects_json(schema_recovery_complete)
@kratos_webhook()
def recovery_complete():
data = request.get_json()
try:
UserService.recovery_complete(data["user_id"])
except Exception as e:
current_app.logger.warn(f"Exception in /users/recovery_complete: {e}")
raise
return jsonify(message="Updated last recovery time.")
# This is supposed to be called by Kratos as a webhook after a user has
# successfully logged in to their account.
@api_v1.route("/users/login_complete", methods=["POST"])
@expects_json(schema_recovery_complete)
@kratos_webhook()
def login_complete():
data = request.get_json()
try:
UserService.login_complete(data["user_id"])
except Exception as e:
current_app.logger.warn(f"Exception in /users/login_complete: {e}")
raise
return jsonify(message="Updated last login time.")
@api_v1.route("/users", methods=["POST"])
@jwt_required()
......@@ -47,8 +98,7 @@ def post_user():
@admin_required()
def put_user(id):
data = request.get_json()
user_id = __get_user_id_from_jwt()
res = UserService.put_user(id, user_id, data)
res = UserService.put_user(id, data)
return jsonify(res)
......@@ -92,7 +142,7 @@ def get_personal_info():
def update_personal_info():
data = request.get_json()
user_id = __get_user_id_from_jwt()
res = UserService.put_user(user_id, user_id, data)
res = UserService.put_user(user_id, data)
return jsonify(res)
......
......@@ -17,7 +17,7 @@ schema = {
"name": {
"type": "string",
"description": "Name of the app",
"minLenght": 1,
"minLength": 1,
},
"role_id": {
"type": ["integer", "null"],
......@@ -28,6 +28,12 @@ schema = {
"required": ["name", "role_id"],
},
},
"tags": {
"type": "array",
"items": {
"type": "integer",
},
},
},
"required": ["email", "app_roles"],
}
......@@ -40,3 +46,63 @@ schema_multiple = {
}
}
}
# Multiple app role edit of existing users
schema_multi_edit = {
"users": {
"type": "array",
"items" : {
"type": "object",
"properties": {
"email": {
"type": "string",
"description": "Email of the user",
"minLength": 1,
},
"id": {
"type": "string",
"description": "UUID of the user",
"minLength": 1,
},
"app_roles": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the app",
"minLength": 1,
},
"role_id": {
"type": ["integer", "null"],
"description": "Role of the user",
"minimum": 1,
},
},
# "required": ["name", "role_id"],
},
},
"tags": {
"type": "array",
"items": {
"type": "integer",
},
},
},
# "required": ["email", "app_roles"],
}
}
}
schema_recovery_complete = {
"type": "object",
"properties": {
"user_id": {
"type": "string",
"description": "Kratos ID of the user",
"minLength": 1,
},
},
"required": ["user_id"],
}
......@@ -7,36 +7,28 @@ the user entries in the database(s)"""
import sys
import click
import hydra_client
import datetime
import ory_kratos_client
from flask import current_app
from flask.cli import AppGroup
from ory_kratos_client.api import v0alpha2_api as kratos_api
from ory_kratos_client.api import identity_api
from sqlalchemy import func
from config import HYDRA_ADMIN_URL, KRATOS_ADMIN_URL, KRATOS_PUBLIC_URL
from helpers import KratosUser
from cliapp import cli
from areas.apps.apps_service import AppsService
from areas.apps.models import AppRole, App
from areas.roles import Role
from areas.apps import AppRole, App
from areas.users import UserService
from database import db
# APIs
# Create HYDRA & KRATOS API interfaces
HYDRA = hydra_client.HydraAdmin(HYDRA_ADMIN_URL)
# Kratos has an admin and public end-point. We create an API for them
# both. The kratos implementation has bugs, which forces us to set
# the discard_unknown_keys to True.
kratos_admin_api_configuration = \
ory_kratos_client.Configuration(host=KRATOS_ADMIN_URL, discard_unknown_keys=True)
KRATOS_ADMIN = \
kratos_api.V0alpha2Api(ory_kratos_client.ApiClient(kratos_admin_api_configuration))
kratos_public_api_configuration = \
ory_kratos_client.Configuration(host=KRATOS_PUBLIC_URL, discard_unknown_keys=True)
KRATOS_PUBLIC = \
kratos_api.V0alpha2Api(ory_kratos_client.ApiClient(kratos_public_api_configuration))
# Kratos has an admin and public end-point. We create an API for the admin one.
kratos_admin_api_configuration = ory_kratos_client.Configuration(host=KRATOS_ADMIN_URL)
kratos_admin_client = ory_kratos_client.ApiClient(kratos_admin_api_configuration)
kratos_identity_api = identity_api.IdentityApi(kratos_admin_client)
##############################################################################
# CLI INTERFACE #
......@@ -59,11 +51,9 @@ def create_app(slug, name, external_url = None):
:param extenal-url: if set, it marks this as an external app and
configures the url
"""
current_app.logger.info(f"Creating app definition: {name} ({slug}")
current_app.logger.info(f"Creating app definition: {name} ({slug})")
obj = App()
obj.name = name
obj.slug = slug
obj = App(name=name, slug=slug)
app_obj = App.query.filter_by(slug=slug).first()
......@@ -90,6 +80,43 @@ def list_app():
print(f"App name: {obj.name}\tSlug: {obj.slug},\tURL: {obj.get_url()}\tStatus: {obj.get_status()}")
@user_cli.command("cleanup")
@click.option("--dry-run", is_flag=True, default=False)
def cleanup_users(dry_run):
"""
Remove users that have never been active and are at least six weeks old.
"""
current_app.logger.info("Listing inactive users")
if dry_run:
print("Dry run, so not deleting anything.")
users = KratosUser.find_all(kratos_identity_api)
number_users = 0
number_inactive_users = 0
for user in users:
number_users = number_users + 1
try:
last_recovery = user.metadata_admin.get('last_recovery')
except (KeyError, AttributeError):
last_recovery = None
if last_recovery is not None:
continue
print(user)
print(f" Created at: {user.created_at}")
# For this long period we ignore any timezone difference.
age = datetime.datetime.now(datetime.timezone.utc) - user.created_at
if age > datetime.timedelta(weeks=6):
print("That's more than 6 weeks ago.")
number_inactive_users = number_inactive_users + 1
if not dry_run:
print("Deleting.")
user.delete()
UserService.delete(user.uuid)
if dry_run:
print(f"Would delete {number_inactive_users} users out of {number_users} total.")
else:
print(f"Deleted {number_inactive_users} users out of {number_users} total.")
@app_cli.command(
"delete",
)
......@@ -165,14 +192,14 @@ def install_app(slug):
if app.external:
current_app.logger.info(
f"App {slug} is an external app and can not be provisioned automatically")
f"App {slug} is an external app and cannot be provisioned automatically")
sys.exit(1)
current_status = app.get_status()
if not current_status.installed:
app.install()
AppsService.install_app(app)
current_app.logger.info(
f"App {slug} installing... use `status` to see status")
f"App {slug} installing...")
else:
current_app.logger.error(f"App {slug} is already installed")
......@@ -207,45 +234,33 @@ def setrole(email, app_slug, role):
"""Set role for a user
:param email: Email address of user to assign role
:param app_slug: Slug name of the app, for example 'nextcloud'
:param role: Role to assign. currently only 'admin', 'user'
:param role: Role to assign. Currently only 'admin', 'user', 'none'/'no
access'.
"""
current_app.logger.info(f"Assigning role {role} to {email} for app {app_slug}")
# Find user
user = KratosUser.find_by_email(KRATOS_ADMIN, email)
if role not in ("admin", "user"):
print("At this point only the roles 'admin' and 'user' are accepted")
sys.exit(1)
user = KratosUser.find_by_email(kratos_identity_api, email)
if not user:
print("User not found. Abort")
sys.exit(1)
app_obj = db.session.query(App).filter(App.slug == app_slug).first()
if not app_obj:
app = db.session.query(App).filter(App.slug == app_slug).first()
if not app:
print("App not found. Abort.")
sys.exit(1)
role_obj = (
db.session.query(AppRole)
.filter(AppRole.app_id == app_obj.id)
.filter(AppRole.user_id == user.uuid)
.first()
)
if role_obj:
db.session.delete(role_obj)
if role == "none":
role = "no access"
role = Role.query.filter(func.lower(Role.name) == func.lower(role)).first()
if not role:
print("Role not found. Abort.")
sys.exit(1)
obj = AppRole()
obj.user_id = user.uuid
obj.app_id = app_obj.id
obj.role_id = role.id if role else None
UserService.set_user_role(user.uuid, app.id, role.id)
db.session.add(obj)
db.session.commit()
......@@ -256,25 +271,26 @@ def show_user(email):
internal state/values of the user object
:param email: Email address of the user to show
"""
user = KratosUser.find_by_email(KRATOS_ADMIN, email)
if user is not None:
print(user)
print("")
print(f"UUID: {user.uuid}")
print(f"Username: {user.username}")
print(f"Updated: {user.updated_at}")
print(f"Created: {user.created_at}")
print(f"State: {user.state}")
print(f"Roles:")
results = db.session.query(AppRole, Role).join(App, Role)\
.add_entity(App).add_entity(Role)\
.filter(AppRole.user_id == user.uuid)
for entry in results:
app = entry[-2]
role = entry[-1]
print(f" {role.name: >9} on {app.name}")
else:
user = KratosUser.find_by_email(kratos_identity_api, email)
if user is None:
print(f"User with email address '{email}' was not found")
return
print(user)
print("")
print(f"UUID: {user.uuid}")
print(f"Username: {user.username}")
print(f"Updated: {user.updated_at}")
print(f"Created: {user.created_at}")
print(f"State: {user.state}")
print(f"Roles:")
results = db.session.query(AppRole)\
.filter_by(user_id=user.uuid)\
.join(App).join(Role)\
.add_entity(App).add_entity(Role)
for entry in results:
app = entry[-2]
role = entry[-1]
print(f" {role.name: >9} on {app.name}")
@user_cli.command("update")
......@@ -288,7 +304,7 @@ def update_user(email, field, value):
:param value: The value to set the field with
"""
current_app.logger.info(f"Looking for user with email: {email}")
user = KratosUser.find_by_email(KRATOS_ADMIN, email)
user = KratosUser.find_by_email(kratos_identity_api, email)
if not user:
current_app.logger.error(f"User with email {email} not found.")
sys.exit(1)
......@@ -300,6 +316,10 @@ def update_user(email, field, value):
else:
current_app.logger.error(f"Field not found: {field}")
# TODO: this currently deletes the last_recovery and last_login because
# `save` uses a simple PUT and is not aware of those fields. We should
# switch to PATCH instead, or refactor so `save` uses the same code as
# `put_user`.
user.save()
......@@ -310,11 +330,11 @@ def delete_user(email):
:param email: Email address of user to delete
"""
current_app.logger.info(f"Looking for user with email: {email}")
user = KratosUser.find_by_email(KRATOS_ADMIN, email)
user = KratosUser.find_by_email(kratos_identity_api, email)
if not user:
current_app.logger.error(f"User with email {email} not found.")
sys.exit(1)
user.delete()
UserService.delete_user(user.uuid)
@user_cli.command("create")
......@@ -327,15 +347,29 @@ def create_user(email):
current_app.logger.info(f"Creating user with email: ({email})")
# Create a user
user = KratosUser.find_by_email(KRATOS_ADMIN, email)
user = KratosUser.find_by_email(kratos_identity_api, email)
if user:
current_app.logger.info("User already exists. Not recreating")
return
user = KratosUser(KRATOS_ADMIN)
user = KratosUser(kratos_identity_api)
user.email = email
user.save()
dashboard_app = db.session.query(App).filter(App.slug == 'dashboard').first()
if not dashboard_app:
print("Dashboard app not found. Aborting.")
sys.exit(1)
user_role = Role.query.filter(func.lower(Role.name) == 'user').first()
if not user_role:
print("User role not found. Aborting.")
sys.exit(1)
UserService.set_user_role(user.uuid, dashboard_app.id, user_role.id)
db.session.commit()
@user_cli.command("setpassword")
@click.argument("email")
......@@ -357,7 +391,7 @@ def setpassword_user(email, password):
try:
# Get the ID of the user
kratos_user = KratosUser.find_by_email(KRATOS_ADMIN, email)
kratos_user = KratosUser.find_by_email(kratos_identity_api, email)
if kratos_user is None:
current_app.logger.error(f"User with email '{email}' not found")
sys.exit(1)
......@@ -382,7 +416,7 @@ def setpassword_user(email, password):
def list_user():
"""Show a list of users in the database"""
current_app.logger.info("Listing users")
users = KratosUser.find_all(KRATOS_ADMIN)
users = KratosUser.find_all(kratos_identity_api)
for obj in users:
print(obj)
......@@ -394,12 +428,10 @@ def recover_user(email):
"""Get recovery link for a user, to manual update the user/use
:param email: Email address of the user
"""
current_app.logger.info(f"Trying to send recover email for user: {email}")
try:
# Get the ID of the user
kratos_user = KratosUser.find_by_email(KRATOS_ADMIN, email)
kratos_user = KratosUser.find_by_email(kratos_identity_api, email)
# Get a recovery URL
url = kratos_user.get_recovery_link()
......@@ -409,4 +441,38 @@ def recover_user(email):
current_app.logger.error(f"Error while getting reset link: {error}")
@user_cli.command("reset_totp")
@click.argument("email")
def reset_totp(email):
"""Remove configured totp second factor for a user.
:param email: Email address of the user
"""
current_app.logger.info(f"Removing totp second factor for user: {email}")
try:
# Get the ID of the user
kratos_user = KratosUser.find_by_email(kratos_identity_api, email)
# Get a recovery URL
UserService.reset_totp(kratos_user.uuid)
except Exception as error: # pylint: disable=broad-except
current_app.logger.error(f"Error while removing totp second factor: {error}")
@user_cli.command("reset_webauthn")
@click.argument("email")
def reset_webauthn(email):
"""Remove configured second factor for a user.
:param email: Email address of the user
"""
current_app.logger.info(f"Removing second factor for user: {email}")
try:
# Get the ID of the user
kratos_user = KratosUser.find_by_email(kratos_identity_api, email)
# Get a recovery URL
UserService.reset_webauthn(kratos_user.uuid)
except Exception as error: # pylint: disable=broad-except
current_app.logger.error(f"Error while removing webauthn second factor: {error}")
cli.cli.add_command(user_cli)
import base64
import jwt
import logging
from string import Template
import yaml
from database import db
from areas.apps.models import App, OAuthClientApp
import helpers.kubernetes as k8s
# Read in two configmaps from the cluster, which specify which apps should be
# present in the database. Returns the list of app slugs.
def populate_apps():
logging.debug("cluster_config: populating apps")
database_apps = {}
for app in App.query.all():
slug = app.slug
database_apps[slug] = app
logging.debug(f"database app: {slug}")
core_apps = _populate_apps_from(database_apps, "stackspin-apps")
custom_apps = _populate_apps_from(database_apps, "stackspin-apps-custom")
return (core_apps + custom_apps)
# Read a list of apps from a configmap. Check if they are already present in
# the database, and if not, add missing ones there. Properties `name`,
# `external` and `url` can be specified in yaml format in the configmap value
# contents. Returns the list of app slugs found.
def _populate_apps_from(database_apps, configmap_name):
slugs = []
cm_apps = k8s.get_kubernetes_config_map_data(configmap_name, "flux-system")
if cm_apps is None:
logging.debug(f"Could not find configmap '{configmap_name}' in namespace 'flux-system'; ignoring.")
else:
for app_slug, app_data in cm_apps.items():
logging.debug(f"configmap app: {app_slug}")
slugs.append(app_slug)
if app_slug in database_apps:
logging.debug(f" already present in database")
else:
logging.debug(f" not present in database, adding!")
data = yaml.safe_load(app_data)
name = data["name"]
logging.debug(f" name: {name}")
external = data.get("external", False)
logging.debug(f" type external: {type(external)}")
logging.debug(f" external: {external}")
url = data.get("url", None)
logging.debug(f" url: {url}")
new_app = App(slug=app_slug, name=name, external=external, url=url)
db.session.add(new_app)
db.session.commit()
return slugs
# Read in two configmaps from the cluster, which specify which oauthclients
# should be present in the database.
def populate_oauthclients():
logging.debug("cluster_config: populating oauthclients")
database_oauthclients = {}
for client in OAuthClientApp.query.all():
id = client.oauthclient_id
database_oauthclients[id] = client
logging.debug(f"database oauthclient: {id}")
_populate_oauthclients_from(database_oauthclients, "stackspin-oauthclients")
_populate_oauthclients_from(database_oauthclients, "stackspin-oauthclients-custom")
# Read a list of oauthclients from a configmap. Check if they are already
# present in the database, and if not, add missing ones there. The value of the
# mapping is taken to be the slug of the app the oauthclient belongs to.
def _populate_oauthclients_from(database_oauthclients, configmap_name):
cm_oauthclients = k8s.get_kubernetes_config_map_data(configmap_name, "flux-system")
if cm_oauthclients is None:
logging.debug(f"Could not find configmap '{configmap_name}' in namespace 'flux-system'; ignoring.")
else:
for client_id, client_app in cm_oauthclients.items():
logging.debug(f"configmap oauthclient: {client_id}")
if client_id in database_oauthclients:
logging.debug(f" already present in database")
else:
logging.debug(f" not present in database, adding!")
# Take the value of the configmap mapping (`client_app`) and
# interpret it as the slug of the app that this oauthclient
# belongs to.
app = App.query.filter_by(slug=client_app).first()
if not app:
logging.error(f" could not find app with slug {client_app}")
continue
new_client = OAuthClientApp(oauthclient_id=client_id, app_id=app.id)
logging.debug(f" new oauth client: {new_client}")
db.session.add(new_client)
db.session.commit()
# Read optional per-app SCIM configuration (URL and token) from secrets.
# Store the results in the database. Needs the list of app slugs to be passed.
def populate_scim_config(apps):
for app in apps:
secret_name = f"stackspin-scim-{app}"
scim_config = k8s.get_kubernetes_secret_data(secret_name, "flux-system")
if scim_config is None:
logging.debug(f"Could not find secret '{secret_name}' in namespace 'flux-system'; ignoring.")
continue
logging.debug(f"Processing secret stackspin-scim-{app}")
app = App.query.filter_by(slug=app).first()
if not app:
logging.error(f" could not find app with slug {app}")
continue
scim_url = scim_config.get("scim_url")
if scim_url is None:
logging.error(f" 'scim_url' is not set")
continue
scim_token = scim_config.get("scim_token")
if scim_token is None:
logging.error(f" 'scim_token' is not set")
continue
scim_url = base64.b64decode(scim_url).decode()
# We substitute the string `$BASE` or `${BASE}` in the `scim_url` by
# the app's base url.
scim_url = Template(scim_url).substitute(BASE=app.get_url())
app.scim_url = scim_url
scim_token = base64.b64decode(scim_token).decode()
scim_jwt = scim_config.get("scim_jwt")
if scim_jwt is not None:
scim_jwt = base64.b64decode(scim_jwt).decode()
if scim_jwt == "nextcloud":
# Nextcloud wants a JWT token containing the username of an existing user.
scim_token = jwt.encode({"sub":"admin"}, scim_token, algorithm="HS256")
else:
logging.error(f" 'jwt' has unknown value {scim_jwt}")
continue
app.scim_token = scim_token
scim_group_support = scim_config.get("scim_group_support")
if scim_group_support is None:
app.scim_group_support = False
else:
scim_group_support = base64.b64decode(scim_group_support).decode()
app.scim_group_support = scim_group_support.lower() in ['1', 'true', 'yes']
logging.info(f"Configuring SCIM for {app} with url {app.scim_url}.")
db.session.commit()
import os
LOG_LEVEL = os.environ.get("LOG_LEVEL")
SECRET_KEY = os.environ.get("SECRET_KEY")
HYDRA_CLIENT_ID = os.environ.get("HYDRA_CLIENT_ID")
HYDRA_CLIENT_SECRET = os.environ.get("HYDRA_CLIENT_SECRET")
HYDRA_AUTHORIZATION_BASE_URL = os.environ.get("HYDRA_AUTHORIZATION_BASE_URL")
TOKEN_URL = os.environ.get("TOKEN_URL")
DASHBOARD_URL = os.environ.get("DASHBOARD_URL")
LOGIN_PANEL_URL = os.environ.get("LOGIN_PANEL_URL")
HYDRA_PUBLIC_URL = os.environ.get("HYDRA_PUBLIC_URL")
......@@ -19,4 +22,22 @@ SQLALCHEMY_TRACK_MODIFICATIONS = False
# Set this to "true" to load the config from a Kubernetes serviceaccount
# running in a Kubernetes pod. Set it to "false" to load the config from the
# `KUBECONFIG` environment variable.
LOAD_INCLUSTER_CONFIG = os.environ.get("LOAD_INCLUSTER_CONFIG").lower() == "true"
LOAD_INCLUSTER_CONFIG = os.environ.get("LOAD_INCLUSTER_CONFIG", "").lower() == "true"
RUN_BY_GUNICORN = "gunicorn" in os.environ.get("SERVER_SOFTWARE", "")
if os.environ.get("TELEPRESENCE_ROOT"):
TELEPRESENCE = True
TELEPRESENCE_MODE = os.environ.get("TELEPRESENCE_MODE")
print(f"TELEPRESENCE_MODE: {TELEPRESENCE_MODE}")
if TELEPRESENCE_MODE == "docker":
KUBECONFIG = os.environ["TELEPRESENCE_MOUNTS"]
else:
KUBECONFIG = os.environ.get("TELEPRESENCE_ROOT") + os.environ["TELEPRESENCE_MOUNTS"]
print(f"KUBECONFIG from telepresence: {KUBECONFIG}")
print(os.stat(KUBECONFIG))
else:
TELEPRESENCE = False
KUBECONFIG = None
DEMO_INSTANCE = os.environ.get("DASHBOARD_DEMO_INSTANCE", "False").lower() in ('true', '1')
ENFORCE_2FA = os.environ.get("DASHBOARD_ENFORCE_2FA", "False").lower() in ('true', '1')
from flask import current_app
from areas.apps.models import App, AppRole
from areas.roles.models import Role
from config import *
from database import db
def user_has_access(user, app):
# Get role on dashboard
dashboard_app = db.session.query(App).filter(
App.slug == 'dashboard').first()
if not dashboard_app:
current_app.logger.error("Dashboard app not found in database.")
return False
role_object = (
db.session.query(AppRole)
.filter(AppRole.app_id == dashboard_app.id)
.filter(AppRole.user_id == user.uuid)
.first()
)
if role_object is None:
current_app.logger.info(f"No dashboard role set for user {user.uuid}.")
return False
# If the user is dashboard admin, they have access to everything.
if role_object.role_id == Role.ADMIN_ROLE_ID:
current_app.logger.info(f"User {user.uuid} has admin dashboard role")
return True
# Get role for app.
role_object = (
db.session.query(AppRole)
.filter(AppRole.app_id == app.id)
.filter(AppRole.user_id == user.uuid)
.first()
)
# Role ID 3 is always "No access" due to migration b514cca2d47b
if role_object is None or role_object.role_id is None or role_object.role_id == Role.NO_ACCESS_ROLE_ID:
current_app.logger.info(f"User {user.uuid} has no access for: {app.name}")
return False
# In all other cases, access is granted.
return True
from functools import wraps
import os
from areas.roles.role_service import RoleService
from helpers import Unauthorized
from flask import request
from flask_jwt_extended import get_jwt, verify_jwt_in_request
from helpers import Unauthorized
def admin_required():
......@@ -22,3 +24,17 @@ def admin_required():
return decorator
return wrapper
def kratos_webhook():
def wrapper(fn):
@wraps(fn)
def decorator(*args, **kwargs):
header = request.headers.get("Authorization")
if header is not None and header == os.environ.get("KRATOS_WEBHOOK_SECRET"):
return fn(*args, **kwargs)
else:
raise Unauthorized("This needs a valid api key.")
return decorator
return wrapper
from flask import jsonify
from jsonschema import ValidationError
import logging
import traceback
class KratosError(Exception):
......@@ -43,6 +45,8 @@ def hydra_error(e):
def global_error(e):
message = str(e)
trace = traceback.format_exception(e)
logging.warning(f"Error in application code: {trace}")
return jsonify({"errorMessage": message}), 500
def unauthorized_error(e):
......
......@@ -6,3 +6,9 @@ class BackendError(Exception):
"""The backend error is raised when interacting with
the backend fails or gives an unexpected result. The
error contains a oneliner description of the problem"""
def __init__(self, message, upstream_exception=None):
# Init of standard Exception class.
super().__init__(message)
# We save the original exception in case the handler wants to inspect
# it.
self.upstream_exception = upstream_exception
......@@ -2,6 +2,7 @@
Implement the Kratos model to interact with kratos users
"""
from flask import current_app
import json
import re
import urllib.parse
......@@ -9,12 +10,12 @@ import urllib.request
from typing import Dict
from urllib.request import Request
# Some imports commented out to satisfy pylint. They will be used once more
# functions are migrated to this model
from ory_kratos_client.model.admin_create_identity_body import AdminCreateIdentityBody
from ory_kratos_client.model.admin_create_self_service_recovery_link_body \
import AdminCreateSelfServiceRecoveryLinkBody
from ory_kratos_client.model.admin_update_identity_body import AdminUpdateIdentityBody
from ory_kratos_client.models.create_identity_body import CreateIdentityBody
from ory_kratos_client.models.create_recovery_link_for_identity_body \
import CreateRecoveryLinkForIdentityBody
from ory_kratos_client.models.json_patch \
import JsonPatch
from ory_kratos_client.models.update_identity_body import UpdateIdentityBody
from ory_kratos_client.rest import ApiException as KratosApiException
from .classes import RedirectFilter
......@@ -35,13 +36,14 @@ class KratosUser():
state = None
created_at = None
updated_at = None
metadata_admin = None
def __init__(self, api, uuid = None):
self.api = api
self.state = 'active'
if uuid:
try:
obj = api.admin_get_identity(uuid)
obj = api.get_identity(uuid)
if obj:
self.__uuid = uuid
try:
......@@ -57,11 +59,14 @@ class KratosUser():
self.state = obj.state
self.created_at = obj.created_at
self.updated_at = obj.updated_at
self.metadata_admin = obj.metadata_admin
if self.metadata_admin is None:
self.metadata_admin = {}
except KratosApiException as error:
raise BackendError(f"Unable to get entry, kratos replied with: {error}") from error
raise BackendError(f"Unable to get entry, kratos replied with: {error}", error) from error
def __repr__(self):
def __str__(self):
return f"\"{self.name}\" <{self.email}>"
@property
......@@ -84,38 +89,46 @@ class KratosUser():
# If we have a UUID, we are updating
if self.__uuid:
body = AdminUpdateIdentityBody(
body = UpdateIdentityBody(
schema_id="default",
state=self.state,
traits=traits,
)
try:
api_response = self.api.admin_update_identity(self.__uuid,
admin_update_identity_body=body)
api_response = self.api.update_identity(self.__uuid,
update_identity_body=body)
except KratosApiException as error:
raise BackendError(f"Unable to save entry, kratos replied with:{error}") from error
else:
body = AdminCreateIdentityBody(
body = CreateIdentityBody(
schema_id="default",
traits=traits,
)
try:
# Create an Identity
api_response = self.api.admin_create_identity(
admin_create_identity_body=body)
api_response = self.api.create_identity(
create_identity_body=body)
if api_response.id:
self.__uuid = api_response.id
except KratosApiException as error:
raise BackendError(f"Unable to save entry, kratos replied with:{error}") from error
def set_metadata(self, **kwargs):
current_app.logger.info(f"Setting metadata for {self.__uuid}:")
patches = []
for k, v in kwargs.items():
current_app.logger.info(f" {k}={v}")
patches.append(JsonPatch(op="replace", path=f"/metadata_admin/{k}", value=v))
self.api.patch_identity(self.__uuid, json_patch=patches)
def delete(self):
"""Deletes the object from kratos
:raise: BackendError if Krator API call fails
"""
if self.__uuid:
try:
self.api.admin_delete_identity(self.__uuid)
self.api.delete_identity(self.__uuid)
return True
except KratosApiException as error:
raise BackendError(
......@@ -134,14 +147,12 @@ class KratosUser():
kratos_id = None
# Get out user ID by iterating over all available IDs
data = api.admin_list_identities()
for kratos_obj in data.value:
# Unique identifier we use
if kratos_obj.traits['email'] == email:
data = api.list_identities(credentials_identifier=email)
for kratos_obj in data:
if kratos_obj.traits['email'].lower() == email.lower():
kratos_id = str(kratos_obj.id)
return KratosUser(api, kratos_id)
# No user found with matching email address.
return None
@staticmethod
......@@ -153,11 +164,20 @@ class KratosUser():
kratos_id = None
return_list = []
# Get out user ID by iterating over all available IDs
data = api.admin_list_identities()
for kratos_obj in data.value:
kratos_id = str(kratos_obj.id)
return_list.append(KratosUser(api, kratos_id))
# Get out user ID by iterating over all available ID
page = 0
while page >= 0:
if page == 0:
data = api.list_identities(per_page=1000)
else:
data = api.list_identities(per_page=1000, page=page)
for kratos_obj in data:
kratos_id = str(kratos_obj.id)
return_list.append(KratosUser(api, kratos_id))
if len(data) == 0:
page = -1
else:
page = page + 1
return return_list
......@@ -202,14 +222,14 @@ class KratosUser():
try:
# Create body request to get recovery link with admin API
body = AdminCreateSelfServiceRecoveryLinkBody(
body = CreateRecoveryLinkForIdentityBody(
expires_in="15m",
identity_id=self.__uuid
)
# Get recovery link from admin API
call = self.api.admin_create_self_service_recovery_link(
admin_create_self_service_recovery_link_body=body)
call = self.api.create_recovery_link_for_identity(
create_recovery_link_for_identity_body=body)
url = call.recovery_link
except KratosApiException:
......@@ -342,9 +362,6 @@ class KratosUser():
return False
raise BackendError("Unable to set password by submitting form")
# Pylint complains about app not used. That is correct, but we will use that
# in the future. Ignore this error
# pylint: disable=unused-argument
def get_claims(self, app, roles, mapping=None) -> Dict[str, Dict[str, str]]:
"""Create openID Connect token
Use the userdata stored in the user object to create an OpenID Connect token.
......@@ -354,7 +371,7 @@ class KratosUser():
Example: getClaims('nextcloud', mapping=[("name", "username"),("roles", "groups")])
Attributes:
appname - Name or ID of app to connect to
appname - client_id of app to connect to
roles - List of roles to add to the `stackspin_roles` claim
mapping - Mapping of the fields
......@@ -380,8 +397,46 @@ class KratosUser():
"preferred_username": username,
"email": self.email,
"stackspin_roles": roles,
# We use this in Nextcloud because the `oidc_login` app wants a
# boolean flag saying whether the user should have admin
# privileges.
"is_admin": "admin" in roles,
# This is also for Nextcloud specifically: we used to use the
# `social_login` app for OIDC, which prefixed all user IDs with
# `stackspin-`. We need to stay compatible with that naming scheme
# until we figure out if and how we want to migrate those prefixes
# away.
"stackspin_prefixed_uuid": "stackspin-" + self.uuid,
}
if app == "wekan":
# This is a non-standard extension to OIDC. It's used in this form
# by Wekan. We don't really have user groups in Stackspin, just an
# admin flag. However as far as I can see, the only way to make
# some users admin in Wekan via OIDC is to have a group for them.
#
# We include a default "stackspin_users" group, because Wekan doesn't
# process group information if the `groups` list is empty, so we
# would not be able to remove a user from the admin group
# otherwise.
#
# Actually Wekan doesn't remove users from groups based on this
# list apparently, but it still implements a correct `isAdmin`
# check based on this group data, which is the part we care about.
groups = [{
"displayName": "Stackspin users",
"isAdmin": False,
"forceCreate": True,
"isActive": True,
}]
if "admin" in roles:
groups.append({
"displayName": "Stackspin admins",
"isAdmin": True,
"forceCreate": True,
"isActive": True,
})
token["groups"] = groups
# Relabel field names
if mapping:
......