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:
Ilya Kreymer 2024-07-01 09:37:02 -07:00 committed by GitHub
parent 3cd52342a7
commit e1ef894275
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 253 additions and 34 deletions

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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] = ""

View File

@ -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(

View File

@ -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
# ============================================================================

View File

@ -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

View File

@ -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/

View File

@ -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 }}