move everything to backend folder for migration to dashboard repository
This commit is contained in:
parent
af6b006409
commit
92ec7c653d
89 changed files with 0 additions and 0 deletions
9
backend/areas/__init__.py
Normal file
9
backend/areas/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from flask import Blueprint
|
||||
|
||||
api_v1 = Blueprint("api_v1", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
@api_v1.route("/")
|
||||
@api_v1.route("/health")
|
||||
def api_index():
|
||||
return "Stackspin API v1.0"
|
||||
3
backend/areas/apps/__init__.py
Normal file
3
backend/areas/apps/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .apps import *
|
||||
from .apps_service import *
|
||||
from .models import *
|
||||
67
backend/areas/apps/apps.py
Normal file
67
backend/areas/apps/apps.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
from flask import jsonify
|
||||
from flask_jwt_extended import jwt_required
|
||||
from flask_cors import cross_origin
|
||||
|
||||
from areas import api_v1
|
||||
from .apps_service import AppsService
|
||||
|
||||
|
||||
CONFIG_DATA = [
|
||||
{
|
||||
"id": "values.yml",
|
||||
"description": "Some user friendly description",
|
||||
"raw": "cronjob:\n # Set curl to accept insecure connections when acme staging is used\n curlInsecure: false",
|
||||
"fields": [
|
||||
{"name": "cronjob", "type": "string", "value": ""},
|
||||
{"name": "curlInsecure", "type": "boolean", "value": "false"}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@api_v1.route('/apps', methods=['GET'])
|
||||
@jwt_required()
|
||||
@cross_origin()
|
||||
def get_apps():
|
||||
"""Return data about all apps"""
|
||||
apps = AppsService.get_all_apps()
|
||||
return jsonify(apps)
|
||||
|
||||
|
||||
@api_v1.route('/apps/<string:slug>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_app(slug):
|
||||
"""Return data about a single app"""
|
||||
app = AppsService.get_app(slug)
|
||||
return jsonify(app)
|
||||
|
||||
|
||||
@api_v1.route('/apps', methods=['POST'])
|
||||
@jwt_required()
|
||||
@cross_origin()
|
||||
def post_app():
|
||||
"""Unused function, returns bogus data for now"""
|
||||
return jsonify([]), 201
|
||||
|
||||
|
||||
@api_v1.route('/apps/<string:slug>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@cross_origin()
|
||||
def put_app(slug):
|
||||
"""Unused function, returns bogus data for now"""
|
||||
return jsonify([])
|
||||
|
||||
|
||||
@api_v1.route('/apps/<string:slug>/config', methods=['GET'])
|
||||
@jwt_required()
|
||||
@cross_origin()
|
||||
def get_config(slug):
|
||||
"""Returns bogus config data"""
|
||||
return jsonify(CONFIG_DATA)
|
||||
|
||||
|
||||
@api_v1.route('/apps/<string:slug>/config', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@cross_origin()
|
||||
def delete_config(slug):
|
||||
"""Does nothing, then returns bogus config data"""
|
||||
return jsonify(CONFIG_DATA)
|
||||
17
backend/areas/apps/apps_service.py
Normal file
17
backend/areas/apps/apps_service.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from .models import App, AppRole
|
||||
|
||||
class AppsService:
|
||||
@staticmethod
|
||||
def get_all_apps():
|
||||
apps = App.query.all()
|
||||
return [app.to_dict() for app in apps]
|
||||
|
||||
@staticmethod
|
||||
def get_app(slug):
|
||||
app = App.query.filter_by(slug=slug).first()
|
||||
return app.to_dict()
|
||||
|
||||
@staticmethod
|
||||
def get_app_roles():
|
||||
app_roles = AppRole.query.all()
|
||||
return [{"user_id": app_role.user_id, "app_id": app_role.app_id, "role_id": app_role.role_id} for app_role in app_roles]
|
||||
298
backend/areas/apps/models.py
Normal file
298
backend/areas/apps/models.py
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
"""Everything to do with Apps"""
|
||||
|
||||
import os
|
||||
import base64
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from database import db
|
||||
import helpers.kubernetes as k8s
|
||||
|
||||
|
||||
DEFAULT_APP_SUBDOMAINS = {
|
||||
"nextcloud": "files",
|
||||
"wordpress": "www",
|
||||
"monitoring": "grafana",
|
||||
}
|
||||
|
||||
class App(db.Model):
|
||||
"""
|
||||
The App object, interact with the App database object. Data is stored in
|
||||
the local database.
|
||||
"""
|
||||
|
||||
id = db.Column(Integer, primary_key=True)
|
||||
name = db.Column(String(length=64))
|
||||
slug = db.Column(String(length=64), unique=True)
|
||||
external = db.Column(Boolean, unique=False, nullable=False, server_default='0')
|
||||
# The URL is only stored in the DB for external applications; otherwise the
|
||||
# URL is stored in a configmap (see get_url)
|
||||
url = db.Column(String(length=128), unique=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.id} <{self.name}>"
|
||||
|
||||
def get_url(self):
|
||||
"""
|
||||
Returns the URL where this application is running
|
||||
|
||||
For external applications: the URL is stored in the database
|
||||
|
||||
For internal applications: the URL is stored in a configmap named
|
||||
`stackspin-{self.slug}-kustomization-variables under
|
||||
`{self.slug_domain}`. This function reads that configmap. If the
|
||||
configmap does not contain a URL for the application (which is
|
||||
possible, if the app is not installed yet, for example), we return a
|
||||
default URL.
|
||||
"""
|
||||
|
||||
if self.external:
|
||||
return self.url
|
||||
|
||||
# Get domain name from configmap
|
||||
ks_config_map = k8s.get_kubernetes_config_map_data(
|
||||
f"stackspin-{self.slug}-kustomization-variables",
|
||||
"flux-system")
|
||||
domain_key = f"{self.slug}_domain"
|
||||
|
||||
# If config map found with this domain name for this service, return
|
||||
# that URL
|
||||
if ks_config_map and domain_key in ks_config_map.keys():
|
||||
return f"https://{ks_config_map[domain_key]}"
|
||||
|
||||
domain_secret = k8s.get_kubernetes_secret_data(
|
||||
"stackspin-cluster-variables",
|
||||
"flux-system")
|
||||
domain = base64.b64decode(domain_secret['domain']).decode()
|
||||
|
||||
# See if there is another default subdomain for this app than just
|
||||
# "slug.{domain}"
|
||||
if self.slug in DEFAULT_APP_SUBDOMAINS:
|
||||
return f"https://{DEFAULT_APP_SUBDOMAINS[self.slug]}.{domain}"
|
||||
|
||||
# No default known
|
||||
return f"https://{self.slug}.{domain}"
|
||||
|
||||
def get_status(self):
|
||||
"""Returns an AppStatus object that describes the current cluster state"""
|
||||
return AppStatus(self)
|
||||
|
||||
def install(self):
|
||||
"""Creates a Kustomization in the Kubernetes cluster that installs this application"""
|
||||
# Generate the necessary passwords, etc. from a template
|
||||
self.__generate_secrets()
|
||||
# Create add-<app> kustomization
|
||||
self.__create_kustomization()
|
||||
|
||||
def uninstall(self):
|
||||
"""
|
||||
Delete the app kustomization.
|
||||
|
||||
In our case, this triggers a deletion of the app's PVCs (so deletes all
|
||||
data), as well as any other Kustomizations and HelmReleases related to
|
||||
the app. It also triggers a deletion of the OAuth2Client object, but
|
||||
does not delete the secrets generated by the `install` command. It also
|
||||
does not remove the TLS secret generated by cert-manager.
|
||||
"""
|
||||
self.__delete_kustomization()
|
||||
|
||||
def delete(self):
|
||||
"""
|
||||
Fully deletes an application
|
||||
|
||||
This includes user roles, all kubernetes objects and also PVCs, so your
|
||||
data will be *gone*
|
||||
"""
|
||||
# Delete all roles first
|
||||
for role in self.roles:
|
||||
db.session.delete(role)
|
||||
db.session.commit()
|
||||
|
||||
db.session.delete(self)
|
||||
return db.session.commit()
|
||||
|
||||
def __generate_secrets(self):
|
||||
"""Generates passwords for app installation"""
|
||||
# Create app variables secret
|
||||
if self.variables_template_filepath:
|
||||
k8s.create_variables_secret(self.slug, self.variables_template_filepath)
|
||||
|
||||
k8s.create_variables_secret(
|
||||
self.slug,
|
||||
os.path.join(
|
||||
self.__get_templates_dir(),
|
||||
"stackspin-oauth-variables.yaml.jinja"
|
||||
)
|
||||
)
|
||||
|
||||
def __create_kustomization(self):
|
||||
"""Creates the `add-{app_slug}` kustomization in the Kubernetes cluster"""
|
||||
kustomization_template_filepath = \
|
||||
os.path.join(self.__get_templates_dir(),
|
||||
"add-app-kustomization.yaml.jinja")
|
||||
k8s.store_kustomization(kustomization_template_filepath, self.slug)
|
||||
|
||||
def __delete_kustomization(self):
|
||||
"""Deletes kustomization for this app"""
|
||||
k8s.delete_kustomization(f"add-{self.slug}")
|
||||
|
||||
|
||||
@property
|
||||
def variables_template_filepath(self):
|
||||
"""Path to the variables template used to generate secrets the app needs"""
|
||||
variables_template_filepath = os.path.join(self.__get_templates_dir(),
|
||||
f"stackspin-{self.slug}-variables.yaml.jinja")
|
||||
if os.path.exists(variables_template_filepath):
|
||||
return variables_template_filepath
|
||||
return None
|
||||
|
||||
@property
|
||||
def namespace(self):
|
||||
"""
|
||||
Returns the Kubernetes namespace of this app
|
||||
|
||||
FIXME: This should probably become a database field.
|
||||
"""
|
||||
if self.slug in ['nextcloud', 'wordpress', 'wekan', 'zulip']:
|
||||
return 'stackspin-apps'
|
||||
return 'stackspin'
|
||||
|
||||
@property
|
||||
def roles(self):
|
||||
"""
|
||||
All roles that are linked to this app
|
||||
"""
|
||||
return AppRole.query.filter_by(
|
||||
app_id=self.id
|
||||
).all()
|
||||
|
||||
@property
|
||||
def kustomization(self):
|
||||
"""Returns the kustomization object for this app"""
|
||||
return k8s.get_kustomization(self.slug)
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
represent this object as a dict, compatible for JSON output
|
||||
"""
|
||||
|
||||
return {"id": self.id,
|
||||
"name": self.name,
|
||||
"slug": self.slug,
|
||||
"external": self.external,
|
||||
"status": self.get_status().to_dict(),
|
||||
"url": self.get_url()}
|
||||
|
||||
@property
|
||||
def helmreleases(self):
|
||||
"""Returns the helmreleases associated with the kustomization for this app"""
|
||||
return k8s.get_all_helmreleases(self.namespace,
|
||||
f"kustomize.toolkit.fluxcd.io/name={self.slug}")
|
||||
|
||||
@staticmethod
|
||||
def __get_templates_dir():
|
||||
"""Returns directory that contains the Jinja templates used to create app secrets."""
|
||||
return os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates")
|
||||
|
||||
|
||||
class AppRole(db.Model): # pylint: disable=too-few-public-methods
|
||||
"""
|
||||
The AppRole object, stores the roles Users have on Apps
|
||||
"""
|
||||
|
||||
user_id = db.Column(String(length=64), primary_key=True)
|
||||
app_id = db.Column(Integer, ForeignKey("app.id"), primary_key=True)
|
||||
role_id = db.Column(Integer, ForeignKey("role.id"))
|
||||
|
||||
role = relationship("Role")
|
||||
|
||||
def __repr__(self):
|
||||
return (f"role_id: {self.role_id}, user_id: {self.user_id},"
|
||||
f" app_id: {self.app_id}, role: {self.role}")
|
||||
|
||||
class AppStatus(): # pylint: disable=too-few-public-methods
|
||||
"""
|
||||
Represents the status of an app in the Kubernetes cluster.
|
||||
|
||||
This class can answer a few questions, like "is the app installed?", but
|
||||
can also return raw status messages from Kustomizations and HelmReleases
|
||||
|
||||
This constructor sets three variables:
|
||||
|
||||
self.installed (bool): Whether the app should be installed
|
||||
self.ready (bool): Whether the app is installed correctly
|
||||
self.message (str): Information about the status
|
||||
|
||||
:param app: An app of which the kustomization and helmreleases
|
||||
property will be used.
|
||||
:type app: App
|
||||
"""
|
||||
def __init__(self, app):
|
||||
if app.external:
|
||||
self.installed = True
|
||||
self.ready = True
|
||||
self.message = "App is external"
|
||||
return
|
||||
|
||||
kustomization = app.kustomization
|
||||
if kustomization is not None and "status" in kustomization:
|
||||
ks_ready, ks_message = AppStatus.check_condition(kustomization['status'])
|
||||
self.installed = True
|
||||
if ks_ready:
|
||||
self.ready = ks_ready
|
||||
self.message = "Installed"
|
||||
return
|
||||
else:
|
||||
ks_ready = None
|
||||
ks_message = "Kustomization does not exist"
|
||||
self.installed = False
|
||||
self.ready = False
|
||||
self.message = "Not installed"
|
||||
return
|
||||
|
||||
helmreleases = app.helmreleases
|
||||
for helmrelease in helmreleases:
|
||||
hr_status = helmrelease['status']
|
||||
hr_ready, hr_message = AppStatus.check_condition(hr_status)
|
||||
|
||||
# For now, only show the message of the first HR that isn't ready
|
||||
if not hr_ready:
|
||||
self.ready = False
|
||||
self.message = f"HelmRelease {helmrelease['metadata']['name']} status: {hr_message}"
|
||||
return
|
||||
|
||||
# If we end up here, all HRs are ready, but the kustomization is not
|
||||
self.ready = ks_ready
|
||||
self.message = f"App Kustomization status: {ks_message}"
|
||||
|
||||
def __repr__(self):
|
||||
return f"Installed: {self.installed}\tReady: {self.ready}\tMessage: {self.message}"
|
||||
|
||||
@staticmethod
|
||||
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
|
||||
"""
|
||||
for condition in status["conditions"]:
|
||||
if condition["type"] == "Ready":
|
||||
return condition["status"] == "True", condition["message"]
|
||||
return False, "Condition with type 'Ready' not found"
|
||||
|
||||
def to_dict(self):
|
||||
"""Represents this app status as a dict"""
|
||||
return {
|
||||
"installed": self.installed,
|
||||
"ready": self.ready,
|
||||
"message": self.message,
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1beta2
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: add-{{ app }}
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 1h0m0s
|
||||
path: ./flux2/cluster/optional/{{ app }}
|
||||
prune: true
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: stackspin
|
||||
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: stackspin-nextcloud-variables
|
||||
data:
|
||||
nextcloud_password: "{{ 32 | generate_password | b64encode }}"
|
||||
nextcloud_mariadb_password: "{{ 32 | generate_password | b64encode }}"
|
||||
nextcloud_mariadb_root_password: "{{ 32 | generate_password | b64encode }}"
|
||||
nextcloud_redis_password: "{{ 32 | generate_password | b64encode }}"
|
||||
onlyoffice_database_password: "{{ 32 | generate_password | b64encode }}"
|
||||
onlyoffice_jwt_secret: "{{ 32 | generate_password | b64encode }}"
|
||||
onlyoffice_rabbitmq_password: "{{ 32 | generate_password | b64encode }}"
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: stackspin-{{ app }}-oauth-variables
|
||||
data:
|
||||
client_id: "{{ app | b64encode }}"
|
||||
client_secret: "{{ 32 | generate_password | b64encode }}"
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: stackspin-wekan-variables
|
||||
data:
|
||||
mongodb_password: "{{ 32 | generate_password | b64encode }}"
|
||||
mongodb_root_password: "{{ 32 | generate_password | b64encode }}"
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: stackspin-wordpress-variables
|
||||
data:
|
||||
wordpress_admin_password: "{{ 32 | generate_password | b64encode }}"
|
||||
wordpress_mariadb_password: "{{ 32 | generate_password | b64encode }}"
|
||||
wordpress_mariadb_root_password: "{{ 32 | generate_password | b64encode }}"
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: stackspin-zulip-variables
|
||||
data:
|
||||
admin_password: "{{ 32 | generate_password | b64encode }}"
|
||||
memcached_password: "{{ 32 | generate_password | b64encode }}"
|
||||
rabbitmq_password: "{{ 32 | generate_password | b64encode }}"
|
||||
rabbitmq_erlang_cookie: "{{ 32 | generate_password | b64encode }}"
|
||||
redis_password: "{{ 32 | generate_password | b64encode }}"
|
||||
postgresql_password: "{{ 32 | generate_password | b64encode }}"
|
||||
zulip_password: "{{ 32 | generate_password | b64encode }}"
|
||||
1
backend/areas/auth/__init__.py
Normal file
1
backend/areas/auth/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .auth import *
|
||||
67
backend/areas/auth/auth.py
Normal file
67
backend/areas/auth/auth.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
from flask import jsonify, request
|
||||
from flask_jwt_extended import create_access_token
|
||||
from flask_cors import cross_origin
|
||||
from datetime import timedelta
|
||||
|
||||
from areas import api_v1
|
||||
from areas.apps import App, AppRole
|
||||
from config import *
|
||||
from helpers import HydraOauth, BadRequest, KratosApi
|
||||
|
||||
|
||||
@api_v1.route("/login", methods=["POST"])
|
||||
@cross_origin()
|
||||
def login():
|
||||
authorization_url = HydraOauth.authorize()
|
||||
return jsonify({"authorizationUrl": authorization_url})
|
||||
|
||||
|
||||
@api_v1.route("/hydra/callback")
|
||||
@cross_origin()
|
||||
def hydra_callback():
|
||||
state = request.args.get("state")
|
||||
code = request.args.get("code")
|
||||
if state == None:
|
||||
raise BadRequest("Missing state query param")
|
||||
|
||||
if code == None:
|
||||
raise BadRequest("Missing code query param")
|
||||
|
||||
token = HydraOauth.get_token(state, code)
|
||||
user_info = HydraOauth.get_user_info()
|
||||
# Match Kratos identity with Hydra
|
||||
identities = KratosApi.get("/identities")
|
||||
identity = None
|
||||
for i in identities.json():
|
||||
if i["traits"]["email"] == user_info["email"]:
|
||||
identity = i
|
||||
|
||||
access_token = create_access_token(
|
||||
identity=token, expires_delta=timedelta(days=365), additional_claims={"user_id": identity["id"]}
|
||||
)
|
||||
|
||||
apps = App.query.all()
|
||||
app_roles = []
|
||||
for app in apps:
|
||||
tmp_app_role = AppRole.query.filter_by(
|
||||
user_id=identity["id"], app_id=app.id
|
||||
).first()
|
||||
app_roles.append(
|
||||
{
|
||||
"name": app.slug,
|
||||
"role_id": tmp_app_role.role_id if tmp_app_role else None,
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"accessToken": access_token,
|
||||
"userInfo": {
|
||||
"id": identity["id"],
|
||||
"email": user_info["email"],
|
||||
"name": user_info["name"],
|
||||
"preferredUsername": user_info["preferred_username"],
|
||||
"app_roles": app_roles,
|
||||
},
|
||||
}
|
||||
)
|
||||
2
backend/areas/roles/__init__.py
Normal file
2
backend/areas/roles/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .roles import *
|
||||
from .models import *
|
||||
12
backend/areas/roles/models.py
Normal file
12
backend/areas/roles/models.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from sqlalchemy import Integer, String
|
||||
from database import db
|
||||
|
||||
|
||||
class Role(db.Model):
|
||||
NO_ACCESS_ROLE_ID = 3
|
||||
|
||||
id = db.Column(Integer, primary_key=True)
|
||||
name = db.Column(String(length=64))
|
||||
|
||||
def __repr__(self):
|
||||
return f"Role {self.name}"
|
||||
18
backend/areas/roles/role_service.py
Normal file
18
backend/areas/roles/role_service.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from areas.apps.models import AppRole
|
||||
from .models import Role
|
||||
|
||||
|
||||
class RoleService:
|
||||
@staticmethod
|
||||
def get_roles():
|
||||
roles = Role.query.all()
|
||||
return [{"id": r.id, "name": r.name} for r in roles]
|
||||
|
||||
@staticmethod
|
||||
def get_role_by_id(role_id):
|
||||
return Role.query.filter_by(id=role_id).first()
|
||||
|
||||
@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
|
||||
15
backend/areas/roles/roles.py
Normal file
15
backend/areas/roles/roles.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from flask import jsonify, request
|
||||
from flask_jwt_extended import jwt_required
|
||||
from flask_cors import cross_origin
|
||||
|
||||
from areas import api_v1
|
||||
|
||||
from .role_service import RoleService
|
||||
|
||||
|
||||
@api_v1.route("/roles", methods=["GET"])
|
||||
@jwt_required()
|
||||
@cross_origin()
|
||||
def get_roles():
|
||||
roles = RoleService.get_roles()
|
||||
return jsonify(roles)
|
||||
2
backend/areas/users/__init__.py
Normal file
2
backend/areas/users/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .users import *
|
||||
from .user_service import *
|
||||
195
backend/areas/users/user_service.py
Normal file
195
backend/areas/users/user_service.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
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 database import db
|
||||
from areas.apps import App, AppRole, AppsService
|
||||
from areas.roles import Role, RoleService
|
||||
from helpers import KratosApi
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from helpers.error_handler import KratosError
|
||||
|
||||
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))
|
||||
|
||||
return userList
|
||||
|
||||
@staticmethod
|
||||
def get_user(id):
|
||||
res = KratosApi.get("/admin/identities/{}".format(id)).json()
|
||||
return UserService.__insertAppRoleToUser(id, res)
|
||||
|
||||
@staticmethod
|
||||
def post_user(data):
|
||||
kratos_data = {
|
||||
"schema_id": "default",
|
||||
"traits": {
|
||||
"name": data["name"],
|
||||
"email": data["email"],
|
||||
},
|
||||
}
|
||||
res = KratosApi.post("/admin/identities", kratos_data).json()
|
||||
|
||||
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(
|
||||
user_id=res["id"],
|
||||
role_id=ar["role_id"] if "role_id" in ar else Role.NO_ACCESS_ROLE_ID,
|
||||
app_id=app.id,
|
||||
)
|
||||
|
||||
db.session.add(app_role)
|
||||
db.session.commit()
|
||||
else:
|
||||
all_apps = AppsService.get_all_apps()
|
||||
for app in all_apps:
|
||||
app_role = AppRole(
|
||||
user_id=res["id"],
|
||||
role_id=Role.NO_ACCESS_ROLE_ID,
|
||||
app_id=app.id,
|
||||
)
|
||||
|
||||
db.session.add(app_role)
|
||||
db.session.commit()
|
||||
|
||||
UserService.__start_recovery_flow(data["email"])
|
||||
|
||||
return UserService.get_user(res["id"])
|
||||
|
||||
|
||||
@staticmethod
|
||||
def __start_recovery_flow(email):
|
||||
"""
|
||||
Start a Kratos recovery flow for the user's email address.
|
||||
|
||||
This sends out an email to the user that explains to them how they can
|
||||
set their password. Make sure the user exists inside Kratos before you
|
||||
use this function.
|
||||
|
||||
: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']
|
||||
# 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)
|
||||
|
||||
@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)
|
||||
|
||||
if is_admin and 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()
|
||||
|
||||
return UserService.get_user(id)
|
||||
|
||||
@staticmethod
|
||||
def delete_user(id):
|
||||
app_role = AppRole.query.filter_by(user_id=id).all()
|
||||
for ar in app_role:
|
||||
db.session.delete(ar)
|
||||
db.session.commit()
|
||||
|
||||
@staticmethod
|
||||
def post_multiple_users(data):
|
||||
# check if data is array
|
||||
# for every item in array call Kratos
|
||||
created_users = []
|
||||
existing_users = []
|
||||
creation_failed_users = []
|
||||
|
||||
for user_data in data['users']:
|
||||
user_email = user_data["email"]
|
||||
if not user_email:
|
||||
return
|
||||
try:
|
||||
UserService.post_user(user_data)
|
||||
current_app.logger.info(f"Batch create user: {user_email}")
|
||||
created_users.append(user_email)
|
||||
except KratosError as err:
|
||||
status_code = err.args[1]
|
||||
if status_code == 409:
|
||||
existing_users.append(user_email)
|
||||
elif status_code == 400:
|
||||
creation_failed_users.append(user_email)
|
||||
current_app.logger.error(
|
||||
f"Exception calling Kratos: {err} on creating user: {user_email} {status_code}")
|
||||
except Exception as error:
|
||||
current_app.logger.error(
|
||||
f"Exception: {error} on creating user: {user_email}")
|
||||
creation_failed_users.append(user_email)
|
||||
|
||||
success_response = {}
|
||||
existing_response = {}
|
||||
failed_response = {}
|
||||
if created_users:
|
||||
success_response = {"users": created_users,
|
||||
"message": f"{len(created_users)} users created"}
|
||||
if existing_users:
|
||||
existing_response = {
|
||||
"users": existing_users, "message": f"{len(existing_users)} users already exist: {', '.join(existing_users)}"}
|
||||
if creation_failed_users:
|
||||
failed_response = {"users": creation_failed_users,
|
||||
"message": f"{len(creation_failed_users)} users failed to create: {', '.join(creation_failed_users)}"}
|
||||
|
||||
return {"success": success_response, "existing": existing_response, "failed": failed_response}
|
||||
|
||||
@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,
|
||||
}
|
||||
)
|
||||
|
||||
userRes["traits"]["app_roles"] = app_roles
|
||||
return userRes
|
||||
101
backend/areas/users/users.py
Normal file
101
backend/areas/users/users.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
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 areas import api_v1
|
||||
from helpers import KratosApi
|
||||
from helpers.auth_guard import admin_required
|
||||
|
||||
from .validation import schema, schema_multiple
|
||||
from .user_service import UserService
|
||||
|
||||
|
||||
@api_v1.route("/users", methods=["GET"])
|
||||
@jwt_required()
|
||||
@cross_origin()
|
||||
@admin_required()
|
||||
def get_users():
|
||||
res = UserService.get_users()
|
||||
return jsonify(res)
|
||||
|
||||
|
||||
@api_v1.route("/users/<string:id>", methods=["GET"])
|
||||
@jwt_required()
|
||||
@cross_origin()
|
||||
@admin_required()
|
||||
def get_user(id):
|
||||
res = UserService.get_user(id)
|
||||
return jsonify(res)
|
||||
|
||||
|
||||
@api_v1.route("/users", methods=["POST"])
|
||||
@jwt_required()
|
||||
@cross_origin()
|
||||
@expects_json(schema)
|
||||
@admin_required()
|
||||
def post_user():
|
||||
data = request.get_json()
|
||||
res = UserService.post_user(data)
|
||||
return jsonify(res)
|
||||
|
||||
|
||||
@api_v1.route("/users/<string:id>", methods=["PUT"])
|
||||
@jwt_required()
|
||||
@cross_origin()
|
||||
@expects_json(schema)
|
||||
@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)
|
||||
return jsonify(res)
|
||||
|
||||
|
||||
@api_v1.route("/users/<string:id>", methods=["DELETE"])
|
||||
@jwt_required()
|
||||
@cross_origin()
|
||||
@admin_required()
|
||||
def delete_user(id):
|
||||
res = KratosApi.delete("/identities/{}".format(id))
|
||||
if res.status_code == 204:
|
||||
UserService.delete_user(id)
|
||||
return jsonify(), res.status_code
|
||||
return jsonify(res.json()), res.status_code
|
||||
|
||||
|
||||
@api_v1.route("/users-batch", methods=["POST"])
|
||||
@jwt_required()
|
||||
@cross_origin()
|
||||
@expects_json(schema_multiple)
|
||||
@admin_required()
|
||||
def post_multiple_users():
|
||||
"""Expects an array of user JSON schema in request body."""
|
||||
data = request.get_json()
|
||||
res = UserService.post_multiple_users(data)
|
||||
return jsonify(res)
|
||||
|
||||
|
||||
@api_v1.route("/me", methods=["GET"])
|
||||
@jwt_required()
|
||||
@cross_origin()
|
||||
def get_personal_info():
|
||||
user_id = __get_user_id_from_jwt()
|
||||
res = UserService.get_user(user_id)
|
||||
return jsonify(res)
|
||||
|
||||
|
||||
@api_v1.route("/me", methods=["PUT"])
|
||||
@jwt_required()
|
||||
@cross_origin()
|
||||
@expects_json(schema)
|
||||
def update_personal_info():
|
||||
data = request.get_json()
|
||||
user_id = __get_user_id_from_jwt()
|
||||
res = UserService.put_user(user_id, user_id, data)
|
||||
return jsonify(res)
|
||||
|
||||
|
||||
def __get_user_id_from_jwt():
|
||||
claims = get_jwt()
|
||||
return claims["user_id"]
|
||||
42
backend/areas/users/validation.py
Normal file
42
backend/areas/users/validation.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import re
|
||||
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"description": "Email of the user",
|
||||
"pattern": r"(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])",
|
||||
"minLength": 1,
|
||||
},
|
||||
"app_roles": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the app",
|
||||
"minLenght": 1,
|
||||
},
|
||||
"role_id": {
|
||||
"type": ["integer", "null"],
|
||||
"description": "Role of the user",
|
||||
"minimum": 1,
|
||||
},
|
||||
},
|
||||
"required": ["name", "role_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["email", "app_roles"],
|
||||
}
|
||||
|
||||
schema_multiple = {
|
||||
"users": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": schema
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in a new issue