Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions cloudfile_ext/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,14 @@ def register(registry):
# to its explicitly enabled administrator control plane.
from cloudfile_ext.identity import provisioning_enabled
if provisioning_enabled(settings):
from cloudfile_ext.identity_api import AdminExternalIdentityView
# Both registrations must reach the same Etech UID authority. Keep
# the generic CE extension's provider behavior outside this deployment.
if getattr(settings, 'CF_DIRECTORY_URL', ''):
from cloudfile_ext.v2.http import IdentityResolve as identity_view
else:
from cloudfile_ext.identity_api import AdminExternalIdentityView as identity_view
registry.register_urls([
path('api/v2.1/admin/cloudfile/sso/identities/resolve/',
AdminExternalIdentityView.as_view(),
identity_view.as_view(),
name='cloudfile-admin-external-identity'),
])
7 changes: 7 additions & 0 deletions cloudfile_ext/identity_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ def post(self, request):
The trusted caller has already resolved the user in its own directory.
No password is supplied and the account is never chosen by display name.
"""
# Etech has exactly one administrator provisioning authority, even
# when a legacy extension registration is still enabled.
if (getattr(settings, 'CF_DIRECTORY_URL', '')
or request.data.get('identity_source') == 'etech-directory'):
from cloudfile_ext.v2.http import IdentityResolve
return IdentityResolve().post(request)

from seahub.auth.models import SocialAuthUser
from seahub.base.accounts import User
from seahub.profile.models import Profile, DuplicatedContactEmailError
Expand Down
18 changes: 17 additions & 1 deletion cloudfile_ext/v2/ACL_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,13 @@ binding, an inactive account or any check error takes the locked path, which
behaves as before. Readiness lifetime and revocation timing are unchanged.

Staff identity resolution pre-creates a native CE User/Profile only after a fresh
active Directory UID proof. It creates no OAuth subject; first OAuth/OIDC login
active Directory UID proof. New Etech users require the trusted Directory account
(`employee_no`, currently Etech `account_`) and use `account@auth.local`. Numeric
and named accounts, including `admin`, are ordinary non-staff users. New native
names follow Seafile RPC lowercase normalization; UIDs remain byte-exact; `cfadmin`,
missing/invalid accounts and native name collisions reject creation. Existing
UID owners retain their exact historical native names, independent of contact
email. It creates no OAuth subject; first OAuth/OIDC login
reuses that UID's native account. Existing UID collation aliases and inactive
accounts reject. Local CE users without a Profile UID use a disjoint native
principal and do not inherit external group claims.
Expand All @@ -299,3 +305,13 @@ Optional issuer/audience defaults match EAP (`cloudfile-sso` / `eap-directory`).
`CF_DIRECTORY_GROUP_OWNER` is an existing active native service administrator
that owns newly created flat Groups. Redis comes from native `CACHES.default`
LOCATION. These are connection/identity inputs, not feature switches.


OIDC-only login provisioning uses `cloudfile_ext.v2.jit.SQLJITProvisioner` after
ID Token verification and exact signed/UserInfo `userId` agreement, with fresh
active Directory proof. It uses only native User/Profile/SocialAuthUser facts;
no legacy job, audit, outbox or extension schema is required. Staff preparation
never invokes this login adapter. OAuth-only callbacks retain existing-UID
reuse; a second provider adds its own subject binding to the same UID owner.
A native RPC success followed by SQL failure can leave a precreated/unbound
account, as with staff preparation; no password or session is issued on failure.
12 changes: 12 additions & 0 deletions cloudfile_ext/v2/account_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Pure account naming shared by trusted login and administrator provisioning."""
import re


def account_username(account):
"""Follow CE's lowercase account naming; never select by contact email."""
if (not isinstance(account, str) or not re.fullmatch(r'[A-Za-z0-9._-]{1,200}', account)
or account.lower() == 'cfadmin'):
raise ValueError('valid non-reserved Directory account required')
# ccnet_user_manager_add_emailuser lowercases usernames in native RPC.
# Normalize creation in SQL JIT too; stored UID owners are never renamed.
return account.lower() + '@auth.local'
26 changes: 21 additions & 5 deletions cloudfile_ext/v2/etech_directory.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,13 +138,29 @@ def create_group(name):
conn.close()


def verified_account(directory, uid):
"""Prove UID/status; NativeIdentity validates account names on creation only."""
subject = directory.subject(uid)
login, _ = validate_subject(subject, uid)
if subject['status'] != 'active':
raise IdentityUnavailable('Directory account disabled')
# employee_no carries Etech account_, including nonnumeric accounts.
# Only the trusted Directory supplies it; request aliases are not proof.
account = subject['attributes'].get('employee_no')
return login, account


def prepare_user(uid):
with runtime() as (directory, identity, catalog, memberships):
subject = directory.subject(uid)
login, _ = validate_subject(subject, uid)
if subject['status'] != 'active':
raise IdentityUnavailable('Directory account disabled')
return identity.prepare(uid=uid, login_name=login)
login, account = verified_account(directory, uid)
return identity.prepare(uid=uid, login_name=login, account=account)


def provision_oidc_user(*, uid, provider, subject):
"""Internal callback only, after verified ID Token/UserInfo UID equality."""
from .jit import SQLJITProvisioner
with runtime() as (directory, identity, catalog, memberships):
return SQLJITProvisioner(directory, identity).ensure(uid=uid, provider=provider, subject=subject)


def lookup_group(external):
Expand Down
13 changes: 11 additions & 2 deletions cloudfile_ext/v2/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,15 @@ def post(self, request):
# Native staff authentication authorizes lookup; Directory proves new UIDs.
uid = request.data.get('external_user_id')
if (not isinstance(uid, str) or not uid or uid != uid.strip() or len(uid) > 225
or any(ord(c) < 32 or ord(c) == 127 for c in uid)
or request.data.get('login_id') != uid):
return api_error(status.HTTP_400_BAD_REQUEST, 'Stable UID required.')
username = Profile.objects.get_username_by_login_id(uid)
# Check exact stored values after native collation matching; never
# choose an arbitrary owner for a duplicate UID or case alias.
rows = list(Profile.objects.filter(login_id=uid).values_list('user', 'login_id'))
if rows and (len(rows) != 1 or rows[0][1] != uid):
return api_error(status.HTTP_409_CONFLICT, 'Native UID binding conflict.')
username = rows[0][0] if rows else None
if username is None:
# A staff request is not identity proof: independently read the
# scoped Etech Directory before creating any native account.
Expand All @@ -33,12 +39,15 @@ def post(self, request):
except Exception:
return api_error(status.HTTP_503_SERVICE_UNAVAILABLE, 'Trusted user preparation unavailable; sign in through SSO or restore Directory.')
profile = Profile.objects.get_profile_by_user(username)
if profile is None or profile.login_id != uid:
if (profile is None or profile.login_id != uid
or username.lower() in ('cfadmin@auth.local', 'cfadmin@etech.com')):
return api_error(status.HTTP_409_CONFLICT, 'Native UID binding conflict.')
try:
user = User.objects.get(email=username)
except User.DoesNotExist:
return api_error(status.HTTP_404_NOT_FOUND, 'Native user not found.')
if user.username != username or not user.is_active:
return api_error(status.HTTP_409_CONFLICT, 'Native user inactive or changed.')
return Response({'external_user_id': uid, 'login_id': uid,
'email': user.username, 'is_active': bool(user.is_active)})

Expand Down
58 changes: 40 additions & 18 deletions cloudfile_ext/v2/identity.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Stable UID binding using CE's native account/Profile/OAuth facts only.

Ported invariants: exact provider/sub/UID matching, no email/name merging,
and opaque native accounts. No legacy provisioning worker is imported.
Ported invariants: exact provider/sub/UID matching and no email/name merging.
Trusted account naming affects creation only. No legacy worker is imported.
"""
from contextlib import ExitStack
import hashlib
Expand All @@ -10,6 +10,10 @@

from .errors import IdentityConflict, IdentityUnavailable
from .schema import database_lock, identifier
from cloudfile_ext.v2.account_names import account_username


_UNSPECIFIED_ACCOUNT = object()


def text(value, maximum=225):
Expand All @@ -32,6 +36,8 @@ def __init__(self, connection, *, account_schema, hub_schema, issuer, create_acc
self.create_account = create_account

def active(self, username, uid, *, locked=False):
if username.lower() in ('cfadmin@auth.local', 'cfadmin@etech.com'):
raise IdentityConflict('reserved native account')
suffix = ' FOR UPDATE' if locked else ''
with self.connection.cursor() as cursor:
cursor.execute('SELECT user,login_id FROM ' + self.profiles + ' WHERE user=%s OR login_id=%s' + suffix, (username, uid))
Expand All @@ -41,9 +47,9 @@ def active(self, username, uid, *, locked=False):
if cursor.fetchall() != ((username, 1),):
raise IdentityUnavailable('native account inactive')

def prepare(self, *, uid, login_name):
def prepare(self, *, uid, login_name, account=_UNSPECIFIED_ACCOUNT):
"""Pre-create only a Directory-verified UID; never invent an OAuth sub."""
uid, login_name = text(uid), text(login_name, 200)
uid = text(uid)
with database_lock(self.connection, 'identity:uid:' + uid):
with self.connection.cursor() as cursor:
cursor.execute('SELECT user,login_id FROM ' + self.profiles + ' WHERE login_id=%s', (uid,))
Expand All @@ -56,22 +62,38 @@ def prepare(self, *, uid, login_name):
return username
if self.create_account is None:
raise IdentityUnavailable('native account creation unavailable')
username = uuid4().hex + '@auth.local'
self.create_account(username)
self.connection.begin()
try:
# Naming metadata must never block reuse of an exact UID owner.
# An explicitly missing Directory account cannot request UUID fallback.
username = (uuid4().hex + '@auth.local' if account is _UNSPECIFIED_ACCOUNT
else account_username(account))
except ValueError:
raise IdentityConflict('invalid Directory account') from None
login_name = text(login_name, 200)
# Serialize case variants too: CE account lookup is case insensitive.
# Never adopt an unbound native account after a failed RPC/SQL attempt.
with database_lock(self.connection, 'identity:account:' + username.lower()):
with self.connection.cursor() as cursor:
cursor.execute('SELECT email,is_active FROM ' + self.accounts + ' WHERE email=%s FOR UPDATE', (username,))
if cursor.fetchall() != ((username, 1),):
raise IdentityUnavailable('native account creation failed')
# Contact mail and employee number are display attributes;
# only the exact immutable UID owns this native account.
cursor.execute('INSERT INTO ' + self.profiles +
'(user,login_id,nickname,intro,lang_code,contact_email,is_manually_set_contact_email,institution,list_in_address_book) '
"VALUES(%s,%s,%s,'',NULL,NULL,0,'',0)", (username, uid, login_name[:64]))
self.connection.commit()
finally:
self.connection.rollback()
cursor.execute('SELECT user,login_id FROM ' + self.profiles + ' WHERE user=%s', (username,))
if cursor.fetchall():
raise IdentityConflict('native account name already bound')
cursor.execute('SELECT email FROM ' + self.accounts + ' WHERE email=%s', (username,))
if cursor.fetchall():
raise IdentityConflict('native account name already exists')
self.create_account(username)
self.connection.begin()
try:
with self.connection.cursor() as cursor:
cursor.execute('SELECT email,is_active FROM ' + self.accounts + ' WHERE email=%s FOR UPDATE', (username,))
if cursor.fetchall() != ((username, 1),):
raise IdentityUnavailable('native account creation failed')
# UID is the binding; the account only names a new user.
cursor.execute('INSERT INTO ' + self.profiles +
'(user,login_id,nickname,intro,lang_code,contact_email,is_manually_set_contact_email,institution,list_in_address_book) '
"VALUES(%s,%s,%s,'',NULL,NULL,0,'',0)", (username, uid, login_name[:64]))
self.connection.commit()
finally:
self.connection.rollback()
self.active(username, uid)
return username

Expand Down
57 changes: 57 additions & 0 deletions cloudfile_ext/v2/jit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Verified OIDC JIT using only CE User, Profile and SocialAuthUser facts.

This internal callback adapter does not authenticate an HTTP request. The caller
must verify issuer, audience, nonce and matching signed/UserInfo business UID.
Administrator preparation never calls this login authority.
"""
import json

from .errors import IdentityConflict
from .identity import text
from .oauth_identity import UIDBindingError, choose_uid_owner
from .schema import database_lock


class SQLJITProvisioner:
def __init__(self, directory, identity):
self.directory, self.identity = directory, identity

def ensure(self, *, uid, provider, subject):
from .etech_directory import verified_account
uid, provider, subject = text(uid), text(provider, 32), text(subject, 255)
identity, connection = self.identity, self.identity.connection
# Serialize the provider subject before any account creation. A subject
# already owned by a different UID cannot leave another new user behind.
with database_lock(connection, 'identity:oauth:' + provider + ':' + subject):
with connection.cursor() as cursor:
cursor.execute('SELECT username,provider,uid FROM ' + identity.social +
' WHERE provider=%s AND uid=%s', (provider, subject))
bindings = cursor.fetchall()
cursor.execute('SELECT user,login_id FROM ' + identity.profiles + ' WHERE login_id=%s', (uid,))
profiles = cursor.fetchall()
try:
if bindings:
username = choose_uid_owner(uid, profiles, bindings, provider=provider, subject=subject)
identity.active(username, uid)
login, account = verified_account(self.directory, uid)
# Native RPC creates non-staff accounts. This primitive shares
# UID/name locks and collision checks with staff preparation.
username = identity.prepare(uid=uid, login_name=login, account=account)
connection.begin()
try:
identity.active(username, uid, locked=True)
with connection.cursor() as cursor:
cursor.execute('SELECT username,provider,uid FROM ' + identity.social +
' WHERE provider=%s AND uid=%s FOR UPDATE', (provider, subject))
bindings = cursor.fetchall()
choose_uid_owner(uid, [(username, uid)], bindings, provider=provider, subject=subject)
if not bindings:
cursor.execute('INSERT INTO ' + identity.social +
'(username,provider,uid,extra_data) VALUES(%s,%s,%s,%s)',
(username, provider, subject, json.dumps({'userId': uid}, sort_keys=True)))
connection.commit()
finally:
connection.rollback()
return username
except UIDBindingError:
raise IdentityConflict('provider subject or UID conflict') from None
7 changes: 6 additions & 1 deletion cloudfile_ext/v2/memberships.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,15 @@ def validate_subject(value, uid, *, now=None):
if generated.tzinfo is None or not -60 <= (now - generated).total_seconds() <= 60:
raise ValueError()
attrs = value['attributes']
if not isinstance(attrs, dict):
raise ValueError()
login = attrs.get('login_name', attrs.get('employee_no'))
if attrs.get('login_name', login) != attrs.get('employee_no', login):
raise ValueError()
text(login, 200)
# Account metadata names new users only. Existing UID owners and their
# memberships remain valid when that metadata is missing or noncanonical.
if login is not None and not isinstance(login, str):
raise ValueError()
text(value['etag'])
desired = set()
for field, kind in (('organizations', 'org'), ('organization_ancestors', 'org'), ('roles', 'role')):
Expand Down
Loading
Loading