Extends Org Create endpont + shared secret auth (#1897)
Updates the /api/orgs/create endpoint to: - not have name / slug be required, will be renamed on first user via #1870 - support optional quotas - support optional first admin user email, who will receive an invite to join the org. Also supports a new shared secret mechanism, to allow an external automation to access the /api/orgs/create endpoint (and only that endpoint thus far) via a shared secret instead of normal login.
This commit is contained in:
parent
3cd52342a7
commit
e1ef894275
@ -29,6 +29,8 @@ PASSWORD_SECRET = os.environ.get("PASSWORD_SECRET", uuid4().hex)
|
||||
|
||||
JWT_TOKEN_LIFETIME = int(os.environ.get("JWT_TOKEN_LIFETIME_MINUTES", 60)) * 60
|
||||
|
||||
BTRIX_SUBS_APP_API_KEY = os.environ.get("BTRIX_SUBS_APP_API_KEY", "")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
RESET_VERIFY_TOKEN_LIFETIME_MINUTES = 60
|
||||
@ -143,7 +145,9 @@ def init_jwt_auth(user_manager):
|
||||
"""init jwt auth router + current_active_user dependency"""
|
||||
oauth2_scheme = OA2BearerOrQuery(tokenUrl="/api/auth/jwt/login", auto_error=False)
|
||||
|
||||
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
) -> User:
|
||||
try:
|
||||
payload = decode_jwt(token, AUTH_ALLOW_AUD)
|
||||
uid: Optional[str] = payload.get("sub") or payload.get("user_id")
|
||||
@ -157,6 +161,17 @@ def init_jwt_auth(user_manager):
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
async def shared_secret_or_active_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
) -> User:
|
||||
# allow superadmin access if token matches the known shared secret
|
||||
# if the shared secret is set
|
||||
# ensure using a long shared secret (eg. uuid4)
|
||||
if BTRIX_SUBS_APP_API_KEY and token == BTRIX_SUBS_APP_API_KEY:
|
||||
return await user_manager.get_superuser()
|
||||
|
||||
return await get_current_user(token)
|
||||
|
||||
current_active_user = get_current_user
|
||||
|
||||
auth_jwt_router = APIRouter()
|
||||
@ -232,4 +247,4 @@ def init_jwt_auth(user_manager):
|
||||
async def refresh_jwt(user=Depends(current_active_user)):
|
||||
return get_bearer_response(user)
|
||||
|
||||
return auth_jwt_router, current_active_user
|
||||
return auth_jwt_router, current_active_user, shared_secret_or_active_user
|
||||
|
@ -118,7 +118,7 @@ class InviteOps:
|
||||
org=None,
|
||||
allow_existing=False,
|
||||
headers: Optional[dict] = None,
|
||||
):
|
||||
) -> tuple[bool, str]:
|
||||
"""Invite user to org (if not specified, to default org).
|
||||
|
||||
If allow_existing is false, don't allow invites to existing users.
|
||||
@ -156,7 +156,7 @@ class InviteOps:
|
||||
org_name,
|
||||
headers,
|
||||
)
|
||||
return True
|
||||
return True, str(invite_pending.id)
|
||||
|
||||
if not allow_existing:
|
||||
raise HTTPException(status_code=400, detail="User already registered")
|
||||
@ -180,7 +180,7 @@ class InviteOps:
|
||||
invite_pending, org_name, invitee_user.email, invite_code, headers
|
||||
)
|
||||
|
||||
return False
|
||||
return False, invite_code
|
||||
|
||||
async def get_pending_invites(
|
||||
self, org=None, page_size: int = DEFAULT_PAGE_SIZE, page: int = 1
|
||||
|
@ -76,9 +76,18 @@ def main():
|
||||
|
||||
user_manager = init_user_manager(mdb, email, invites)
|
||||
|
||||
current_active_user = init_users_api(app, user_manager)
|
||||
current_active_user, shared_secret_or_active_user = init_users_api(
|
||||
app, user_manager
|
||||
)
|
||||
|
||||
org_ops = init_orgs_api(app, mdb, user_manager, invites, current_active_user)
|
||||
org_ops = init_orgs_api(
|
||||
app,
|
||||
mdb,
|
||||
user_manager,
|
||||
invites,
|
||||
current_active_user,
|
||||
shared_secret_or_active_user,
|
||||
)
|
||||
|
||||
event_webhook_ops = init_event_webhooks_api(mdb, org_ops, app_root)
|
||||
|
||||
|
@ -906,11 +906,6 @@ class RenameOrg(BaseModel):
|
||||
slug: Optional[str] = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
class CreateOrg(RenameOrg):
|
||||
"""Create a new org"""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
class OrgStorageRefs(BaseModel):
|
||||
"""Input model for setting primary storage + optional replicas"""
|
||||
@ -963,6 +958,19 @@ class OrgQuotas(BaseModel):
|
||||
giftedExecMinutes: Optional[int] = 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
class OrgCreate(BaseModel):
|
||||
"""Create a new org"""
|
||||
|
||||
name: Optional[str] = None
|
||||
slug: Optional[str] = None
|
||||
|
||||
firstAdminInviteEmail: Optional[str] = None
|
||||
quotas: Optional[OrgQuotas] = None
|
||||
|
||||
subData: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
class OrgQuotaUpdate(BaseModel):
|
||||
"""Organization quota update (to track changes over time)"""
|
||||
@ -1083,6 +1091,8 @@ class Organization(BaseMongoModel):
|
||||
readOnly: Optional[bool] = False
|
||||
readOnlyReason: Optional[str] = None
|
||||
|
||||
subData: Optional[Dict[str, Any]] = None
|
||||
|
||||
def is_owner(self, user):
|
||||
"""Check if user is owner"""
|
||||
return self._is_auth(user, UserRole.OWNER)
|
||||
@ -1281,7 +1291,7 @@ class UserCreateIn(BaseModel):
|
||||
|
||||
inviteToken: Optional[UUID] = None
|
||||
|
||||
newOrg: bool
|
||||
newOrg: Optional[bool] = False
|
||||
newOrgName: Optional[str] = ""
|
||||
|
||||
|
||||
|
@ -28,7 +28,7 @@ from .models import (
|
||||
OrgReadOnlyUpdate,
|
||||
OrgMetrics,
|
||||
OrgWebhookUrls,
|
||||
CreateOrg,
|
||||
OrgCreate,
|
||||
RenameOrg,
|
||||
UpdateRole,
|
||||
RemovePendingInvite,
|
||||
@ -43,6 +43,7 @@ from .models import (
|
||||
from .pagination import DEFAULT_PAGE_SIZE, paginated_format
|
||||
from .utils import slug_from_name, validate_slug
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .invites import InviteOps
|
||||
else:
|
||||
@ -357,15 +358,12 @@ class OrgOps:
|
||||
|
||||
async def add_user_by_invite(
|
||||
self, invite: InvitePending, user: User
|
||||
) -> Optional[Organization]:
|
||||
) -> Organization:
|
||||
"""Add user to an org from an InvitePending, if any.
|
||||
|
||||
If there's no org to add to (eg. superuser invite), just return.
|
||||
If there's no org to add to, raise exception
|
||||
"""
|
||||
if not invite.oid:
|
||||
return None
|
||||
|
||||
org = await self.get_org_by_id(invite.oid)
|
||||
org = invite.oid and await self.get_org_by_id(invite.oid)
|
||||
if not org:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Invalid Invite Code, No Such Organization"
|
||||
@ -726,8 +724,8 @@ class OrgOps:
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# pylint: disable=too-many-statements
|
||||
def init_orgs_api(app, mdb, user_manager, invites, user_dep):
|
||||
# pylint: disable=too-many-statements, too-many-arguments
|
||||
def init_orgs_api(app, mdb, user_manager, invites, user_dep, user_or_shared_secret_dep):
|
||||
"""Init organizations api router for /orgs"""
|
||||
# pylint: disable=too-many-locals,invalid-name
|
||||
|
||||
@ -801,27 +799,55 @@ def init_orgs_api(app, mdb, user_manager, invites, user_dep):
|
||||
|
||||
@app.post("/orgs/create", tags=["organizations"])
|
||||
async def create_org(
|
||||
new_org: CreateOrg,
|
||||
user: User = Depends(user_dep),
|
||||
new_org: OrgCreate,
|
||||
request: Request,
|
||||
user: User = Depends(user_or_shared_secret_dep),
|
||||
):
|
||||
if not user.is_superuser:
|
||||
raise HTTPException(status_code=403, detail="Not Allowed")
|
||||
|
||||
id_ = uuid4()
|
||||
|
||||
name = new_org.name or str(id_)
|
||||
|
||||
if new_org.slug:
|
||||
validate_slug(new_org.slug)
|
||||
slug = new_org.slug
|
||||
else:
|
||||
slug = slug_from_name(new_org.name)
|
||||
slug = slug_from_name(name)
|
||||
|
||||
org = Organization(
|
||||
id=id_, name=new_org.name, slug=slug, users={}, storage=ops.default_primary
|
||||
id=id_,
|
||||
name=name,
|
||||
slug=slug,
|
||||
users={},
|
||||
storage=ops.default_primary,
|
||||
quotas=new_org.quotas or OrgQuotas(),
|
||||
subData=new_org.subData,
|
||||
)
|
||||
if not await ops.add_org(org):
|
||||
return {"added": False, "error": "already_exists"}
|
||||
|
||||
return {"id": id_, "added": True}
|
||||
result = {"added": True, "id": id_}
|
||||
|
||||
if new_org.firstAdminInviteEmail:
|
||||
new_user, token = await invites.invite_user(
|
||||
InviteToOrgRequest(
|
||||
email=new_org.firstAdminInviteEmail, role=UserRole.OWNER
|
||||
),
|
||||
user,
|
||||
user_manager,
|
||||
org=org,
|
||||
allow_existing=True,
|
||||
headers=request.headers,
|
||||
)
|
||||
if new_user:
|
||||
result["invited"] = "new_user"
|
||||
else:
|
||||
result["invited"] = "existing_user"
|
||||
result["token"] = token
|
||||
|
||||
return result
|
||||
|
||||
@router.get("", tags=["organizations"])
|
||||
async def get_org(
|
||||
@ -918,14 +944,15 @@ def init_orgs_api(app, mdb, user_manager, invites, user_dep):
|
||||
org: Organization = Depends(org_owner_dep),
|
||||
user: User = Depends(user_dep),
|
||||
):
|
||||
if await invites.invite_user(
|
||||
new_user, _ = await invites.invite_user(
|
||||
invite,
|
||||
user,
|
||||
user_manager,
|
||||
org=org,
|
||||
allow_existing=True,
|
||||
headers=request.headers,
|
||||
):
|
||||
)
|
||||
if new_user:
|
||||
return {"invited": "new_user"}
|
||||
|
||||
return {"invited": "existing_user"}
|
||||
@ -935,7 +962,8 @@ def init_orgs_api(app, mdb, user_manager, invites, user_dep):
|
||||
invite = await invites.accept_user_invite(user, token, user_manager)
|
||||
|
||||
org = await ops.add_user_by_invite(invite, user)
|
||||
return {"added": True, "org": org}
|
||||
org_out = await org.serialize_for_user(user, user_manager)
|
||||
return {"added": True, "org": org_out}
|
||||
|
||||
@router.get("/invites", tags=["invites"])
|
||||
async def get_pending_org_invites(
|
||||
|
@ -643,10 +643,12 @@ def init_user_manager(mdb, emailsender, invites):
|
||||
|
||||
# ============================================================================
|
||||
# pylint: disable=too-many-locals, raise-missing-from
|
||||
def init_users_api(app, user_manager: UserManager) -> APIRouter:
|
||||
def init_users_api(app, user_manager: UserManager):
|
||||
"""init fastapi_users"""
|
||||
|
||||
auth_jwt_router, current_active_user = init_jwt_auth(user_manager)
|
||||
auth_jwt_router, current_active_user, shared_secret_or_active_user = init_jwt_auth(
|
||||
user_manager
|
||||
)
|
||||
|
||||
app.include_router(
|
||||
auth_jwt_router,
|
||||
@ -666,7 +668,7 @@ def init_users_api(app, user_manager: UserManager) -> APIRouter:
|
||||
tags=["users"],
|
||||
)
|
||||
|
||||
return current_active_user
|
||||
return current_active_user, shared_secret_or_active_user
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
@ -7,6 +7,10 @@ from .conftest import API_PREFIX
|
||||
|
||||
new_oid = None
|
||||
|
||||
new_subs_oid = None
|
||||
|
||||
VALID_PASSWORD = "ValidPassW0rd!"
|
||||
|
||||
|
||||
def test_ensure_only_one_default_org(admin_auth_headers):
|
||||
r = requests.get(f"{API_PREFIX}/orgs", headers=admin_auth_headers)
|
||||
@ -555,3 +559,147 @@ def test_update_read_only(admin_auth_headers, default_org_id):
|
||||
assert data["readOnly"] is False
|
||||
# Test that reason is unset when readOnly is set to false, even implicitly
|
||||
assert data["readOnlyReason"] == ""
|
||||
|
||||
|
||||
def test_create_org_and_invite_new_user(admin_auth_headers):
|
||||
invite_email = "new-user@example.com"
|
||||
r = requests.post(
|
||||
f"{API_PREFIX}/orgs/create",
|
||||
headers=admin_auth_headers,
|
||||
json={
|
||||
"firstAdminInviteEmail": invite_email,
|
||||
"quotas": {
|
||||
"maxPagesPerCrawl": 100,
|
||||
"maxConcurrentCrawls": 1,
|
||||
"storageQuota": 1000000,
|
||||
"maxExecMinutesPerMonth": 1000,
|
||||
},
|
||||
"subData": {"extra": "data", "sub": {"id": 123}},
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["added"]
|
||||
|
||||
org_id = data["id"]
|
||||
|
||||
assert data["invited"] == "new_user"
|
||||
token = data["token"]
|
||||
|
||||
# Look up token
|
||||
r = requests.get(
|
||||
f"{API_PREFIX}/orgs/{org_id}/invites",
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
|
||||
assert data["total"] == 1
|
||||
|
||||
invites = data["items"]
|
||||
assert len(invites) == 1
|
||||
invite = invites[0]
|
||||
|
||||
assert invite["email"] == invite_email
|
||||
assert token == invite["id"]
|
||||
|
||||
global new_subs_oid
|
||||
new_subs_oid = org_id
|
||||
|
||||
# Create user with invite
|
||||
r = requests.post(
|
||||
f"{API_PREFIX}/auth/register",
|
||||
headers=admin_auth_headers,
|
||||
json={
|
||||
"name": "Test User",
|
||||
"email": invite_email,
|
||||
"password": VALID_PASSWORD,
|
||||
"inviteToken": token,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
|
||||
|
||||
def test_validate_new_org_with_quotas_and_user(admin_auth_headers):
|
||||
r = requests.get(f"{API_PREFIX}/orgs/{new_subs_oid}", headers=admin_auth_headers)
|
||||
assert r.status_code == 200
|
||||
|
||||
data = r.json()
|
||||
assert data["slug"] == "test-users-archive"
|
||||
assert data["name"] == "Test User's Archive"
|
||||
|
||||
assert data["quotas"] == {
|
||||
"maxPagesPerCrawl": 100,
|
||||
"maxConcurrentCrawls": 1,
|
||||
"storageQuota": 1000000,
|
||||
"maxExecMinutesPerMonth": 1000,
|
||||
"extraExecMinutes": 0,
|
||||
"giftedExecMinutes": 0,
|
||||
}
|
||||
assert "subData" not in data
|
||||
|
||||
|
||||
def test_create_org_and_invite_existing_user(admin_auth_headers):
|
||||
invite_email = "new-user@example.com"
|
||||
r = requests.post(
|
||||
f"{API_PREFIX}/orgs/create",
|
||||
headers=admin_auth_headers,
|
||||
json={
|
||||
"firstAdminInviteEmail": invite_email,
|
||||
"quotas": {
|
||||
"maxPagesPerCrawl": 100,
|
||||
"maxConcurrentCrawls": 1,
|
||||
"storageQuota": 1000000,
|
||||
"maxExecMinutesPerMonth": 1000,
|
||||
},
|
||||
"subData": {"extra": "data", "sub": {"id": 123}},
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["added"]
|
||||
|
||||
org_id = data["id"]
|
||||
|
||||
assert data["invited"] == "existing_user"
|
||||
token = data["token"]
|
||||
|
||||
r = requests.post(
|
||||
f"{API_PREFIX}/auth/jwt/login",
|
||||
data={
|
||||
"username": invite_email,
|
||||
"password": VALID_PASSWORD,
|
||||
"grant_type": "password",
|
||||
},
|
||||
)
|
||||
data = r.json()
|
||||
assert r.status_code == 200
|
||||
login_token = data["access_token"]
|
||||
|
||||
auth_headers = {"Authorization": "bearer " + login_token}
|
||||
|
||||
# Accept existing user invite
|
||||
r = requests.post(
|
||||
f"{API_PREFIX}/orgs/invite-accept/{token}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["added"]
|
||||
org = data["org"]
|
||||
|
||||
assert org["id"] == org_id
|
||||
assert org["name"] == "Test User's Archive 2"
|
||||
assert org["slug"] == "test-users-archive-2"
|
||||
|
||||
assert org["quotas"] == {
|
||||
"maxPagesPerCrawl": 100,
|
||||
"maxConcurrentCrawls": 1,
|
||||
"storageQuota": 1000000,
|
||||
"maxExecMinutesPerMonth": 1000,
|
||||
"extraExecMinutes": 0,
|
||||
"giftedExecMinutes": 0,
|
||||
}
|
||||
assert "subData" not in org
|
||||
|
@ -79,6 +79,13 @@ spec:
|
||||
- name: MOTOR_MAX_WORKERS
|
||||
value: "{{ .Values.backend_mongodb_workers | default 1 }}"
|
||||
|
||||
- name: BTRIX_SUBS_APP_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: btrix-subs-app-secret
|
||||
key: BTRIX_SUBS_APP_API_KEY
|
||||
optional: true
|
||||
|
||||
volumeMounts:
|
||||
- name: ops-configs
|
||||
mountPath: /ops-configs/
|
||||
|
@ -6,7 +6,7 @@ metadata:
|
||||
namespace: {{ .Release.Namespace }}
|
||||
|
||||
data:
|
||||
APP_ORIGIN: {{ .Values.ingress.tls | ternary "https" "http" }}://{{ .Values.ingress.host | default "localhost:9870" }}
|
||||
APP_ORIGIN: {{ .Values.ingress.tls | ternary "https" "http" }}://{{ or .Values.ingress.host ( print "localhost:" ( .Values.local_service_port | default 9870 )) }}
|
||||
|
||||
CRAWLER_NAMESPACE: {{ .Values.crawler_namespace }}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user