Skip to content

Commit 2b7f7e3

Browse files
Added full admin access table and endpoint (#300)
* Added full admin access table and endpoint * Added method for inviting users * Fixed refreshing kratos cache * Function for sending emails * SMTP variables and login * Removed unused code * PR comments * Added SSO provider to the invite users request * PR comments * Removed comments * PR comments * PR commentz * PR comments * chore: pytest * Submoduled merged --------- Co-authored-by: andhreljaKern <[email protected]>
1 parent a722b71 commit 2b7f7e3

File tree

9 files changed

+301
-5
lines changed

9 files changed

+301
-5
lines changed
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Added full admin table
2+
3+
Revision ID: eb96f9b82cc1
4+
Revises: 455cb5890ac1
5+
Create Date: 2025-04-24 09:12:33.200446
6+
7+
"""
8+
9+
from alembic import op
10+
import sqlalchemy as sa
11+
from sqlalchemy.dialects import postgresql
12+
13+
# revision identifiers, used by Alembic.
14+
revision = "eb96f9b82cc1"
15+
down_revision = "455cb5890ac1"
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
# ### commands auto generated by Alembic - please adjust! ###
22+
op.create_table(
23+
"full_admin_access",
24+
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
25+
sa.Column("email", sa.String(), nullable=True),
26+
sa.Column("meta_info", sa.JSON(), nullable=True),
27+
sa.PrimaryKeyConstraint("id"),
28+
sa.UniqueConstraint("email"),
29+
schema="global",
30+
)
31+
32+
op.execute(
33+
"""
34+
INSERT INTO global.full_admin_access (id, email, meta_info)
35+
VALUES
36+
(gen_random_uuid(), '[email protected]','{}'),
37+
(gen_random_uuid(), '[email protected]','{}'),
38+
(gen_random_uuid(), '[email protected]','{}'),
39+
(gen_random_uuid(), '[email protected]','{}'),
40+
(gen_random_uuid(), '[email protected]','{}'),
41+
(gen_random_uuid(), '[email protected]','{}'),
42+
(gen_random_uuid(), '[email protected]','{}'),
43+
(gen_random_uuid(), '[email protected]','{}'),
44+
(gen_random_uuid(), '[email protected]','{}')
45+
"""
46+
)
47+
48+
# ### end Alembic commands ###
49+
50+
51+
def downgrade():
52+
# ### commands auto generated by Alembic - please adjust! ###
53+
op.drop_table("full_admin_access", schema="global")
54+
# ### end Alembic commands ###

controller/auth/kratos.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from email.mime.text import MIMEText
2+
import smtplib
13
from typing import Union, Any, List, Dict
24
import os
35
import requests
@@ -13,6 +15,10 @@
1315
logger.setLevel(logging.DEBUG)
1416

1517
KRATOS_ADMIN_URL = os.getenv("KRATOS_ADMIN_URL")
18+
SMTP_HOST = os.getenv("SMTP_HOST")
19+
SMTP_PORT = os.getenv("SMTP_PORT")
20+
SMTP_USER = os.getenv("SMTP_USER")
21+
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
1622

1723
# user_id -> {"identity" -> full identity, "simple" -> {"id": str, "mail": str, "firstName": str, "lastName": str}}
1824
# "collected" -> timestamp
@@ -156,7 +162,7 @@ def resolve_user_name_by_id(user_id: str) -> Dict[str, str]:
156162
i = __get_identity(user_id, False)
157163
if i:
158164
i = i["identity"]
159-
return i["traits"]["name"]
165+
return i["traits"]["name"] if "name" in i["traits"] else None
160166
return None
161167

162168

@@ -191,3 +197,69 @@ def resolve_user_name_and_email_by_id(user_id: str) -> dict:
191197
if i and "traits" in i and i["traits"]:
192198
return i["traits"]["name"], i["traits"]["email"]
193199
return None
200+
201+
202+
def create_user_kratos(email: str, provider: str = None):
203+
payload_registration = {
204+
"schema_id": "default",
205+
"traits": {"email": email},
206+
}
207+
if provider:
208+
payload_registration["metadata_public"] = {
209+
"registration_scope": {
210+
"provider_id": provider,
211+
"invitation_sso": True,
212+
}
213+
}
214+
response_create = requests.post(
215+
f"{KRATOS_ADMIN_URL}/identities",
216+
json=payload_registration,
217+
)
218+
return response_create.json() if response_create.ok else None
219+
220+
221+
def delete_user_kratos(user_id: str) -> bool:
222+
response_delete = requests.delete(f"{KRATOS_ADMIN_URL}/identities/{user_id}")
223+
if response_delete.ok:
224+
del KRATOS_IDENTITY_CACHE[user_id]
225+
return True
226+
return False
227+
228+
229+
def get_recovery_link(user_id: str) -> str:
230+
payload_recovery_link = {
231+
"expires_in": "48h",
232+
"identity_id": user_id,
233+
}
234+
response_link = requests.post(
235+
f"{KRATOS_ADMIN_URL}/recovery/link", json=payload_recovery_link
236+
)
237+
return response_link.json() if response_link.ok else None
238+
239+
240+
def email_with_link(to_email: str, recovery_link: str) -> None:
241+
msg = MIMEText(
242+
f"Welcome! Click the link to complete your account setup:\n\n{recovery_link}"
243+
)
244+
msg["Subject"] = "You're invited to our app!"
245+
msg["From"] = "[email protected]"
246+
msg["To"] = to_email
247+
248+
with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
249+
if SMTP_USER and SMTP_PASSWORD:
250+
server.ehlo()
251+
server.starttls()
252+
server.login(SMTP_USER, SMTP_PASSWORD)
253+
server.send_message(msg)
254+
255+
256+
def check_user_exists(email: str) -> bool:
257+
request = requests.get(
258+
f"{KRATOS_ADMIN_URL}/identities?preview_credentials_identifier_similar={quote(email)}"
259+
)
260+
if request.ok:
261+
identities = request.json()
262+
for i in identities:
263+
if i["traits"]["email"].lower() == email.lower():
264+
return True
265+
return False

controller/auth/manager.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
from typing import Any, Dict
1+
import re
2+
from typing import Any, Dict, List, Optional
23

4+
from controller.auth import kratos
35
from fastapi import Request
46
from exceptions.exceptions import (
57
AuthManagerError,
@@ -11,11 +13,14 @@
1113
from controller.organization import manager as organization_manager
1214
from submodules.model import enums, exceptions
1315
from submodules.model.business_objects import organization
16+
from submodules.model.business_objects.user import check_email_in_full_admin
1417
from submodules.model.models import Organization, Project, User
1518
import sqlalchemy
1619

1720
DEV_USER_ID = "741df1c2-a531-43b6-b259-df23bc78e9a2"
1821

22+
EMAIL_RE = re.compile(r"[\w\.-]+@[\w\.-]+\.\w+")
23+
1924

2025
def get_organization_id_by_info(info) -> Organization:
2126
user = get_user_by_info(info)
@@ -152,3 +157,54 @@ def extract_state_info(request: Request, key: str) -> Any:
152157
return value
153158

154159
return request.state.parsed[key]
160+
161+
162+
def check_is_full_admin(request: Any) -> bool:
163+
if request.url.hostname == "localhost" and request.url.port == 7051:
164+
return True
165+
if check_is_admin(request):
166+
jwt_decoded: Dict[str, Any] = jwt.decode(
167+
request.headers["Authorization"].split(" ")[1],
168+
options={"verify_signature": False},
169+
)
170+
subject: Dict[str, Any] = jwt_decoded["session"]["identity"]
171+
if check_email_in_full_admin(subject["traits"]["email"]):
172+
return True
173+
return False
174+
175+
176+
def invite_users(
177+
emails: List[str], organization_name: str, provider: Optional[str] = None
178+
):
179+
user_ids = []
180+
for email in emails:
181+
# Create accounts for the email
182+
user = kratos.create_user_kratos(email, provider)
183+
if not user:
184+
raise AuthManagerError("User creation failed")
185+
user_ids.append(user["id"])
186+
# Assign the account to the organization
187+
user_manager.update_organization_of_user(organization_name, email)
188+
189+
# Get the recovery link for the email
190+
recovery_link = kratos.get_recovery_link(user["id"])
191+
if not recovery_link:
192+
raise AuthManagerError("Failed to get recovery link")
193+
194+
# Send the recovery link to the email
195+
kratos.email_with_link(email, recovery_link["recovery_link"])
196+
return user_ids
197+
198+
199+
def check_valid_emails(emails: List[str]):
200+
valid_emails = [
201+
email
202+
for email in emails
203+
if is_valid_email(email) and not kratos.check_user_exists(email)
204+
]
205+
all_valid = len(valid_emails) == len(emails)
206+
return {"valid_emails": valid_emails, "all_valid": all_valid}
207+
208+
209+
def is_valid_email(email: str) -> bool:
210+
return bool(EMAIL_RE.fullmatch(email))

fast_api/models.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,3 +501,13 @@ class EvaluationRunDeletionBody(BaseModel):
501501
class SearchQuestionReformulationBody(BaseModel):
502502
question: StrictStr
503503
apiKey: StrictStr
504+
505+
506+
class InviteUsersBody(BaseModel):
507+
emails: List[StrictStr]
508+
organization_name: StrictStr
509+
provider: Optional[StrictStr] = None
510+
511+
512+
class CheckInviteUsersBody(BaseModel):
513+
emails: List[StrictStr]

fast_api/routes/misc.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
from exceptions.exceptions import AuthManagerError
12
from fastapi import APIRouter, Body, Request, status
23
from fastapi.responses import PlainTextResponse
34
from fast_api.models import (
45
CancelTaskBody,
6+
CheckInviteUsersBody,
7+
InviteUsersBody,
58
ModelProviderDeleteModelBody,
69
ModelProviderDownloadModelBody,
710
CreateCustomerButton,
@@ -271,3 +274,25 @@ def update_customer_buttons(
271274
update_request.visible,
272275
)
273276
)
277+
278+
279+
@router.get("/is-full-admin")
280+
def get_is_full_admin(request: Request) -> Dict:
281+
data = auth.check_is_full_admin(request)
282+
return pack_json_result(data)
283+
284+
285+
@router.post("/invite-users")
286+
def invite_users(request: Request, body: InviteUsersBody = Body(...)):
287+
if not auth.check_is_full_admin(request):
288+
raise AuthManagerError("Full admin access required")
289+
data = auth.invite_users(body.emails, body.organization_name, body.provider)
290+
return pack_json_result(data)
291+
292+
293+
@router.post("/check-valid-emails")
294+
def check_valid_emails(request: Request, body: CheckInviteUsersBody = Body(...)):
295+
if not auth.check_is_full_admin(request):
296+
raise AuthManagerError("Full admin access required")
297+
data = auth.check_valid_emails(body.emails)
298+
return pack_json_result(data)

fast_api/routes/organization.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import json
2+
from controller.auth import kratos
23
from fastapi import APIRouter, Request, Body
34
from fast_api.models import (
45
AddUserToOrganizationBody,
@@ -90,6 +91,7 @@ def get_user_info(request: Request):
9091
# in use cognition-ui & admin dashboard (07.01.25)
9192
@router.get("/get-user-info-extended")
9293
def get_user_info_extended(request: Request):
94+
kratos.__refresh_identity_cache()
9395
user = auth_manager.get_user_by_info(request.state.info)
9496
name = resolve_user_name_by_id(user.id)
9597
user_dict = {
@@ -98,8 +100,8 @@ def get_user_info_extended(request: Request):
98100
column_whitelist=USER_INFO_WHITELIST,
99101
column_rename_map=USER_INFO_RENAME_MAP,
100102
),
101-
"first_name": name.get("first"),
102-
"last_name": name.get("last"),
103+
"first_name": name.get("first") if name else None,
104+
"last_name": name.get("last") if name else None,
103105
}
104106

105107
return pack_json_result(user_dict)

start

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ docker run -d --rm \
117117
-e SECRET_KEY=default \
118118
-e POSTGRES_POOL_USE_LIFO=x \
119119
-e KERN_S3_ENDPOINT=${MINIO_ENDPOINT} \
120+
-e SMTP_HOST=mailhog \
121+
-e SMTP_PORT=1025 \
120122
-v "$INFERENCE_DIR":/inference \
121123
-v "$LOG_DIR":/logs \
122124
-v "$CONFIG_DIR":/config \
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from fastapi.testclient import TestClient
2+
from controller.auth.kratos import delete_user_kratos
3+
4+
from submodules.model.models import Organization
5+
import requests
6+
import time
7+
8+
9+
def test_is_full_admin(client: TestClient):
10+
"""
11+
Test validate user is an administrator
12+
13+
Args:
14+
client (TestClient): The test client for making API requests.
15+
"""
16+
response = client.get("/api/v1/misc/is-full-admin")
17+
assert response.status_code == 200
18+
response_data = response.json()
19+
20+
assert response_data is True
21+
22+
23+
def test_valid_emails(client: TestClient):
24+
valid_emails_to_test = ["[email protected]", "[email protected]"]
25+
response = client.post(
26+
"/api/v1/misc/check-valid-emails",
27+
json={"emails": valid_emails_to_test},
28+
)
29+
assert response.status_code == 200
30+
response_data = response.json()
31+
32+
assert response_data["allValid"] is True
33+
assert len(response_data["validEmails"]) == len(valid_emails_to_test)
34+
35+
36+
def test_invalid_emails(client: TestClient):
37+
valid_emails_to_test = ["[email protected]", "[email protected]"]
38+
invalid_emails_to_test = ["test.kern.ai", "devtools@kern"]
39+
response = client.post(
40+
"/api/v1/misc/check-valid-emails",
41+
json={"emails": valid_emails_to_test + invalid_emails_to_test},
42+
)
43+
assert response.status_code == 200
44+
response_data = response.json()
45+
46+
assert response_data["allValid"] is False
47+
assert len(response_data["validEmails"]) == len(valid_emails_to_test)
48+
49+
50+
def test_invite_users(client: TestClient, org: Organization):
51+
requests.delete("http://mailhog:8025/api/v1/messages")
52+
valid_emails_to_test = ["[email protected]"]
53+
response = client.post(
54+
"/api/v1/misc/invite-users",
55+
json={"organization_name": org.name, "emails": valid_emails_to_test},
56+
)
57+
assert response.status_code == 200
58+
created_user_ids = response.json()
59+
60+
email_response_data = {"total": 0}
61+
start_time = time.time()
62+
while email_response_data["total"] == 0 and time.time() - start_time < 5:
63+
email_response = requests.get(
64+
"http://mailhog:8025/api/v2/search",
65+
params={"kind": "to", "query": "[email protected]"},
66+
)
67+
email_response_data = email_response.json()
68+
assert email_response.status_code == 200
69+
70+
for user_id in created_user_ids:
71+
delete_user_kratos(user_id)
72+
73+
assert len(email_response_data["items"]) == len(valid_emails_to_test)
74+
assert email_response_data["total"] == len(valid_emails_to_test)
75+
assert email_response_data["count"] == len(valid_emails_to_test)

0 commit comments

Comments
 (0)