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 1390 additions and 231 deletions
import ory_kratos_client
from ory_kratos_client.model.update_recovery_flow_body \
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 config import KRATOS_ADMIN_URL
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_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)
class UserService:
@staticmethod
def get_users():
page = 1
userList = []
while page > 0:
res = KratosApi.get("/admin/identities?per_page=1000&page={}".format(page)).json()
for r in res:
# removed the app role assignment function, passing simple user data
# userList.append(UserService.__insertAppRoleToUser(r["id"], r))
userList.append(r)
if len(res) == 0:
page = -1
else:
page = page + 1
return userList
@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 create_recovery_link(id):
......@@ -50,8 +50,8 @@ class UserService:
res = KratosApi.post("/admin/recovery/link", kratos_data).json()
return res
@staticmethod
def post_user(data):
@classmethod
def post_user(cls, data):
kratos_data = {
"schema_id": "default",
"traits": {
......@@ -72,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:
......@@ -83,14 +82,28 @@ class UserService:
)
db.session.add(app_role)
db.session.commit()
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.
UserService.__start_recovery_flow(data["email"])
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):
......@@ -105,85 +118,82 @@ class UserService:
:type email: str
"""
api_response = kratos_frontend_api.create_native_recovery_flow()
flow = api_response['id']
flow = api_response.id
# Submit the recovery flow to send an email to the new user.
update_recovery_flow_body = \
UpdateRecoveryFlowBody(method="link", email=email)
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)
is_admin = RoleService.is_user_admin(user_editing_id)
@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)
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)
return UserService.get_user(id)
if data.get("tags") is not None:
UserStackspinData.setTags(id, data["tags"])
@staticmethod
def put_multiple_users(user_editing_id, data):
for user_data in data["users"]:
kratos_data = {
# "schema_id": "default",
"traits": {"email": user_data["email"]},
}
KratosApi.put("/admin/identities/{}".format(user_data["id"]), kratos_data)
is_admin = RoleService.is_user_admin(user_editing_id)
if is_admin and user_data["app_roles"]:
app_roles = user_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=user_data["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=user_Data["id"],
role_id=ar["role_id"] if "role_id" in ar else None,
app_id=app.id,
)
db.session.add(appRole)
db.session.commit()
return UserService.get_user(user_data["id"])
db.session.commit()
request_provision()
return cls.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 = []
......@@ -195,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:
......@@ -211,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 = {}
......@@ -226,6 +237,22 @@ 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()
......
......@@ -5,9 +5,9 @@ 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, schema_multi_edit
from .validation import schema, schema_multiple, schema_multi_edit, schema_recovery_complete
from .user_service import UserService
......@@ -36,6 +36,50 @@ 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()
@cross_origin()
......@@ -54,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)
......@@ -83,18 +126,6 @@ def post_multiple_users():
return jsonify(res)
# multi-user editing of app roles
@api_v1.route("/users-multi-edit", methods=["PUT"])
@jwt_required()
@cross_origin()
@expects_json(schema_multi_edit)
@admin_required()
def put_multiple_users():
data = request.get_json()
user_id = __get_user_id_from_jwt()
res = UserService.put_multiple_users(user_id, data)
return jsonify(res)
@api_v1.route("/me", methods=["GET"])
@jwt_required()
@cross_origin()
......@@ -111,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)
......
......@@ -28,6 +28,12 @@ schema = {
"required": ["name", "role_id"],
},
},
"tags": {
"type": "array",
"items": {
"type": "integer",
},
},
},
"required": ["email", "app_roles"],
}
......@@ -77,8 +83,26 @@ schema_multi_edit = {
# "required": ["name", "role_id"],
},
},
"tags": {
"type": "array",
"items": {
"type": "integer",
},
},
},
# "required": ["email", "app_roles"],
}
}
}
\ No newline at end of file
}
schema_recovery_complete = {
"type": "object",
"properties": {
"user_id": {
"type": "string",
"description": "Kratos ID of the user",
"minLength": 1,
},
},
"required": ["user_id"],
}
......@@ -7,7 +7,7 @@ the user entries in the database(s)"""
import sys
import click
import ory_hydra_client
import datetime
import ory_kratos_client
from flask import current_app
from flask.cli import AppGroup
......@@ -17,17 +17,16 @@ 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
# Kratos has an admin and public end-point. We create an API for the admin one.
# 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_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)
......@@ -52,7 +51,7 @@ 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(name=name, slug=slug)
......@@ -81,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",
)
......@@ -156,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")
......@@ -198,7 +234,8 @@ 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}")
......@@ -206,37 +243,24 @@ def setrole(email, app_slug, role):
# Find user
user = KratosUser.find_by_email(kratos_identity_api, email)
if role not in ("admin", "user"):
print("At this point only the roles 'admin' and 'user' are accepted")
sys.exit(1)
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()
......@@ -248,24 +272,25 @@ def show_user(email):
:param email: Email address of the user to show
"""
user = KratosUser.find_by_email(kratos_identity_api, 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:
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")
......@@ -291,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()
......@@ -305,7 +334,7 @@ def delete_user(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,6 +356,20 @@ def create_user(email):
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")
......@@ -385,9 +428,7 @@ 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_identity_api, email)
......@@ -400,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
import logging
import yaml
# Read in two configmaps from the cluster, which specify which apps should be
# present in the database.
# present in the database. Returns the list of app slugs.
def populate_apps():
logging.info("cluster_config: populating 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}")
_populate_apps_from(database_apps, "stackspin-apps")
_populate_apps_from(database_apps, "stackspin-apps-custom")
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.
# 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.info(f"Could not find configmap '{configmap_name}' in namespace 'flux-system'; ignoring.")
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:
......@@ -43,11 +49,12 @@ def _populate_apps_from(database_apps, configmap_name):
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.info("cluster_config: populating oauthclients")
logging.debug("cluster_config: populating oauthclients")
database_oauthclients = {}
for client in OAuthClientApp.query.all():
id = client.oauthclient_id
......@@ -62,7 +69,7 @@ def populate_oauthclients():
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.info(f"Could not find configmap '{configmap_name}' in namespace 'flux-system'; ignoring.")
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}")
......@@ -81,3 +88,50 @@ def _populate_oauthclients_from(database_oauthclients, configmap_name):
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")
......@@ -20,7 +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 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,10 +10,12 @@ import urllib.request
from typing import Dict
from urllib.request import Request
from ory_kratos_client.model.create_identity_body import CreateIdentityBody
from ory_kratos_client.model.create_recovery_link_for_identity_body \
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.model.update_identity_body import UpdateIdentityBody
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
......@@ -33,6 +36,7 @@ class KratosUser():
state = None
created_at = None
updated_at = None
metadata_admin = None
def __init__(self, api, uuid = None):
self.api = api
......@@ -55,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
......@@ -107,6 +114,14 @@ class KratosUser():
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
......@@ -132,20 +147,12 @@ class KratosUser():
kratos_id = None
# Get out user ID by iterating over all available IDs
page = 1
while page > 0:
data = api.list_identities(per_page=1000, page=page)
for kratos_obj in data:
# Unique identifier we use
if kratos_obj.traits['email'] == email:
kratos_id = str(kratos_obj.id)
return KratosUser(api, kratos_id)
if len(data) == 0:
page = -1
else:
page = page + 1
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
......@@ -158,9 +165,12 @@ class KratosUser():
kratos_id = None
return_list = []
# Get out user ID by iterating over all available ID
page = 1
while page > 0:
data = api.list_identities(per_page=1000, page=page)
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))
......
"""
List of functions to get data from Flux Kustomizations and Helmreleases
"""
import crypt
import functools
import secrets
import string
import threading
import jinja2
import yaml
from kubernetes import client, config
from kubernetes import client, config, watch
from kubernetes.config.incluster_config import InClusterConfigLoader
from kubernetes.client import api_client
from kubernetes.client.exceptions import ApiException
from kubernetes.utils import create_from_yaml
from kubernetes.utils.create_from_yaml import FailToCreateError
from flask import current_app
from config import LOAD_INCLUSTER_CONFIG
from config import KUBECONFIG, LOAD_INCLUSTER_CONFIG, TELEPRESENCE
# Load the kube config once
#
# By default this loads whatever we define in the `KUBECONFIG` env variable,
# otherwise loads the config from default locations, similar to what kubectl
# does.
if LOAD_INCLUSTER_CONFIG:
if TELEPRESENCE:
print(f"token_filename: {KUBECONFIG}/token")
import os
if os.path.isfile(f"{KUBECONFIG}/token"):
print("token_filename exists")
else:
print("token_filename does not exist")
InClusterConfigLoader(
token_filename=f"{KUBECONFIG}/token",
cert_filename=f"{KUBECONFIG}/ca.crt"
).load_and_set()
elif LOAD_INCLUSTER_CONFIG:
config.load_incluster_config()
else:
config.load_kube_config()
......@@ -53,7 +66,7 @@ def create_variables_secret(app_slug, variables_filepath):
elif current_secret_data.keys() != new_secret_dict["data"].keys():
# Update current secret with new keys
update_secret = True
current_app.logger.info(
current_app.logger.debug(
f"Secret {secret_name} in namespace {secret_namespace}"
" already exists. Merging..."
)
......@@ -61,12 +74,12 @@ def create_variables_secret(app_slug, variables_filepath):
new_secret_dict["data"] |= current_secret_data
else:
# Do Nothing
current_app.logger.info(
current_app.logger.debug(
f"Secret {secret_name} in namespace {secret_namespace}"
" is already in a good state, doing nothing."
)
return True
current_app.logger.info(
current_app.logger.debug(
f"Storing secret {secret_name} in namespace"
f" {secret_namespace} in cluster."
)
......@@ -170,9 +183,9 @@ def store_kubernetes_secret(secret_dict, namespace, update=False):
namespace=namespace
)
except FailToCreateError as ex:
current_app.logger.info(f"Secret not created because of exception {ex}")
current_app.logger.warning(f"Secret not created because of exception {ex}")
raise ex
current_app.logger.info(f"Secret {verb} with api response: {api_response}")
current_app.logger.debug(f"Secret {verb} with api response: {api_response}")
def store_kustomization(kustomization_template_filepath, app_slug):
......@@ -198,7 +211,7 @@ def store_kustomization(kustomization_template_filepath, app_slug):
plural="kustomizations",
body=kustomization_dict)
except FailToCreateError as ex:
current_app.logger.info(
current_app.logger.warning(
f"Could not create {app_slug} Kustomization because of exception {ex}")
raise ex
current_app.logger.debug(f"Kustomization created with api response: {api_response}")
......@@ -230,7 +243,7 @@ def delete_kustomization(kustomization_name):
name=kustomization_name,
body=body)
except ApiException as ex:
current_app.logger.info(
current_app.logger.warning(
f"Could not delete {kustomization_name} Kustomization because of exception {ex}")
raise ex
current_app.logger.debug(f"Kustomization deleted with api response: {api_response}")
......@@ -295,20 +308,6 @@ def generate_password(length):
return password
def gen_htpasswd(user, password):
"""
Generate htpasswd entry for user with password.
:param user: Username used in the htpasswd entry
:type user: string
:param password: Password for the user, will get encrypted.
:type password: string
:return: htpassword line entry
:rtype: string
"""
return f"{user}:{crypt.crypt(password, crypt.mksalt(crypt.METHOD_SHA512))}"
def get_all_kustomizations(namespace='flux-system'):
"""
Returns all flux kustomizations in a namespace.
......@@ -409,3 +408,84 @@ def get_gitrepo(name, namespace='flux-system'):
# Raise all non-404 errors
raise error
return resource
def debounce(timeout: float):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
wrapper.func.cancel()
wrapper.func = threading.Timer(timeout, func, args, kwargs)
wrapper.func.start()
wrapper.func = threading.Timer(timeout, lambda: None)
return wrapper
return decorator
def watch_dashboard_config(app, reload):
# Number of seconds to wait before reloading in case more secrets show up.
# In particular this prevents us from reloading once for every
# secret that exists at startup in succession.
debounce_timeout = 1
@debounce(debounce_timeout)
def debounced_reload():
reload()
w = watch.Watch()
api_instance = client.CoreV1Api(api_client.ApiClient())
def watch_scim_secrets():
with app.app_context():
for event in w.stream(
api_instance.list_namespaced_secret,
'flux-system',
label_selector="stackspin.net/scim-config=1",
watch=True
):
current_app.logger.debug(f"{event['type']} SCIM config secret: {event['object'].metadata.name}")
debounced_reload()
threading.Thread(target=watch_scim_secrets).start()
def watch_dashboard_configmaps():
with app.app_context():
for event in w.stream(
api_instance.list_namespaced_config_map,
'flux-system',
label_selector="stackspin.net/dashboard-config=1",
watch=True
):
current_app.logger.debug(f"{event['type']} dashboard config configmap: {event['object'].metadata.name}")
debounced_reload()
threading.Thread(target=watch_dashboard_configmaps).start()
def check_condition(status):
"""
Returns a tuple that has true/false for readiness and a message
Ready, in this case means that the condition's type == "Ready" and its
status == "True". If the condition type "Ready" does not occur, the
status is interpreted as not ready.
The message that is returned is the message that comes with the
condition with type "Ready"
:param status: Kubernetes resource's "status" object.
:type status: dict
"""
if status["observedGeneration"] == -1:
return False, "Kustomization is not yet seen by controller"
for condition in status["conditions"]:
if condition["type"] == "Ready":
return condition["status"] == "True", condition["message"]
return False, "Condition with type 'Ready' not found"
def wait_kustomization_ready(app):
w = watch.Watch()
api_instance = client.CustomObjectsApi()
for event in w.stream(api_instance.list_namespaced_custom_object, 'kustomize.toolkit.fluxcd.io', 'v1', 'flux-system', 'kustomizations'):
ks = event['object']
if ks['metadata']['name'] != app.slug:
# We're currently only interested in the `app` app.
continue
ks_ready, ks_message = check_condition(ks['status'])
if not ks_ready:
# There is some data on the app kustomization, but it's not ready
# yet.
continue
print(f"Kustomization {app.slug} is now ready.")
return
This diff is collapsed.
import functools
from posix_ipc import MessageQueue, O_CREAT, BusyError
import threading
# Signal to provisioning loop that we want to provision now.
provisioning_queue = MessageQueue('/stackspin-dashboard-provision-queue', O_CREAT)
def debounce(timeout: float):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
wrapper.func.cancel()
wrapper.func = threading.Timer(timeout, func, args, kwargs)
wrapper.func.start()
wrapper.func = threading.Timer(timeout, lambda: None)
return wrapper
return decorator
@debounce(1)
def request_provision():
try:
provisioning_queue.send("provision", timeout=0)
except BusyError:
# If we can't signal for some reason (queue limit reached), silently
# fail.
pass
def wait_provision():
# We first wait until there's any message in the queue.
provisioning_queue.receive()
# After that, we check if there are any more messages, to prevent a couple
# of (long) provisioning runs to be done back-to-back in case of multiple
# provisioning requests. Note however that if a request comes in during the
# middle of a provisioning run, we still do another one right after to make
# sure we propagate the latest changes right away.
try:
while True:
# We read with zero timeout, so we get an exception right away if
# the queue is empty.
provisioning_queue.receive(timeout=0)
except BusyError:
pass
from sqlalchemy import exc
from sqlalchemy import exc, text
from database import db
import logging
......@@ -17,7 +17,7 @@ def reset():
logging.info("Checking if alembic version needs to be reset.")
version = None
try:
result = db.session.execute("select version_num from alembic_version")
result = db.session.execute(text("select version_num from alembic_version"))
for row in result:
version = row[0]
except exc.ProgrammingError:
......
"""Extend SCIM support to dynamic apps
Revision ID: 267d280db490
Revises: 825262488cd9
Create Date: 2024-04-12 11:49:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '267d280db490'
down_revision = '825262488cd9'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"app",
sa.Column(
"scim_url",
sa.Unicode(length=1024),
nullable=True
),
)
op.add_column(
"app",
sa.Column(
"scim_token",
sa.Unicode(length=1024),
nullable=True
),
)
op.add_column(
"app",
sa.Column(
"scim_group_support",
sa.Boolean(),
server_default='0',
nullable=False
),
)
# ID of user in app for SCIM purposes. The dashboard needs this so it can
# construct the SCIM URL to the app identifying the user.
op.add_column(
"app_role",
sa.Column(
"scim_id",
sa.Unicode(length=256),
nullable=True
),
)
op.create_index(
"app_role__app_id__scim_id",
"app_role",
["app_id", "scim_id"],
unique=False
)
def downgrade():
op.drop_column("app", "scim_url")
op.drop_column("app", "scim_token")
op.drop_column("app", "scim_group_support")
op.drop_index("app_role__app_id__scim_id", "app_role")
op.drop_column("app_role", "scim_id")
......@@ -16,11 +16,11 @@ down_revision = None
branch_labels = None
depends_on = None
conn = op.get_bind()
inspector = Inspector.from_engine(conn)
tables = inspector.get_table_names()
def upgrade():
conn = op.get_bind()
inspector = Inspector.from_engine(conn)
tables = inspector.get_table_names()
if "app" not in tables:
op.create_table(
"app",
......@@ -69,4 +69,4 @@ def downgrade():
op.drop_table("oauthclient_app")
op.drop_table("app_role")
op.drop_table("role")
op.drop_table("app")
\ No newline at end of file
op.drop_table("app")
"""Add SCIM support for user provisioning
Revision ID: 825262488cd9
Revises: fdb28e81f5c2
Create Date: 2023-03-08 10:50:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
from sqlalchemy.engine.reflection import Inspector
from helpers.provision import ProvisionStatus
# revision identifiers, used by Alembic.
revision = '825262488cd9'
down_revision = 'fdb28e81f5c2'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"app_role",
sa.Column(
"provision_status",
sa.Enum(
ProvisionStatus,
native_enum=False,
length=32,
values_callable=lambda _: [str(member.value) for member in ProvisionStatus]
),
nullable=False,
default=ProvisionStatus.SyncNeeded,
server_default=ProvisionStatus.SyncNeeded.value
),
)
op.add_column(
"app_role",
sa.Column(
"last_provision_attempt",
sa.DateTime,
nullable=True
),
)
op.add_column(
"app_role",
sa.Column(
"last_provision_message",
sa.Unicode(length=256),
nullable=True
),
)
def downgrade():
op.drop_column("app_role", "provision_status")
op.drop_column("app_role", "last_provision_attempt")
op.drop_column("app_role", "last_provision_message")
"""Extend SCIM support to include some attributes during provisioning only when
they are changed, or the user is first created in the app.
Revision ID: 9ee5a7d65fa7
Revises: 267d280db490
Create Date: 2024-06-04 15:39:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '9ee5a7d65fa7'
down_revision = '267d280db490'
branch_labels = None
depends_on = None
def upgrade():
# An entry in this table records that a certain user attribute needs to be
# set in a certain app via SCIM.
op.create_table(
"scim_attribute",
sa.Column("user_id", sa.String(length=64), nullable=False),
sa.Column("app_id", sa.Integer(), nullable=False),
sa.Column("attribute", sa.String(length=64), nullable=False),
sa.PrimaryKeyConstraint("user_id", "app_id", "attribute"),
sa.ForeignKeyConstraint(["app_id"],["app.id"]),
)
def downgrade():
op.drop_table("scim_attribute")
"""Add tags for user management.
Revision ID: fdb28e81f5c2
Revises: 7d27395c892a
Create Date: 2023-11-21 14:55:00
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
from sqlalchemy.engine.reflection import Inspector
# revision identifiers, used by Alembic.
revision = 'fdb28e81f5c2'
down_revision = '7d27395c892a'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"tag",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=256), nullable=False),
sa.Column("colour", sa.String(length=64), nullable=True),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
"tag_user",
sa.Column("user_id", sa.String(length=64), nullable=False),
sa.Column("tag_id", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("user_id", "tag_id"),
sa.ForeignKeyConstraint(["tag_id"],["tag.id"]),
)
def downgrade():
op.drop_table("tag_user")
op.drop_table("tag")
APScheduler==3.11.0
# CLI creation kit
click==8.1.8
cryptography==44.0.2
Flask==3.1.0
Flask-Cors==5.0.1
flask-expects-json==1.7.0
Flask-JWT-Extended==4.7.1
Flask-Migrate==4.1.0
Flask-SQLAlchemy==3.1.1
gunicorn==23.0.0
jsonschema==4.23.0
# Templating kustomizations as part of app installation.
jinja2-base64-filters==0.1.4
kubernetes==32.0.1
pymysql==1.1.1
NamedAtomicLock==1.1.3
ory-kratos-client==1.3.8
ory-hydra-client==2.2.0
pip-install==1.3.5
posix-ipc==1.1.1
PyYAML==6.0.2
regex==2024.11.6
requests==2.32.3
requests-oauthlib==2.0.0