use admin GSSAPI creds for some API endpoints #45
|
@ -72,6 +72,7 @@ addprinc -pw krb5 sysadmin/admin
|
||||||
addprinc -pw krb5 ctdalek
|
addprinc -pw krb5 ctdalek
|
||||||
addprinc -pw krb5 exec1
|
addprinc -pw krb5 exec1
|
||||||
addprinc -pw krb5 regular1
|
addprinc -pw krb5 regular1
|
||||||
|
addprinc -pw krb5 office1
|
||||||
addprinc -randkey host/auth1.csclub.internal
|
addprinc -randkey host/auth1.csclub.internal
|
||||||
addprinc -randkey ldap/auth1.csclub.internal
|
addprinc -randkey ldap/auth1.csclub.internal
|
||||||
ktadd host/auth1.csclub.internal
|
ktadd host/auth1.csclub.internal
|
||||||
|
|
|
@ -6,6 +6,9 @@ sed -E '/^(domain|search)[[:space:]]+csclub.uwaterloo.ca/d' /etc/resolv.conf > /
|
||||||
cp /tmp/resolv.conf /etc/resolv.conf
|
cp /tmp/resolv.conf /etc/resolv.conf
|
||||||
rm /tmp/resolv.conf
|
rm /tmp/resolv.conf
|
||||||
|
|
||||||
|
# normally systemd creates /run/ceod for us
|
||||||
|
mkdir -p /run/ceod
|
||||||
|
|
||||||
# mock out systemctl
|
# mock out systemctl
|
||||||
ln -s /bin/true /usr/local/bin/systemctl
|
ln -s /bin/true /usr/local/bin/systemctl
|
||||||
# mock out acme.sh
|
# mock out acme.sh
|
||||||
|
@ -40,8 +43,8 @@ sync_with() {
|
||||||
port=$2
|
port=$2
|
||||||
fi
|
fi
|
||||||
synced=false
|
synced=false
|
||||||
# give it 5 minutes
|
# give it 20 minutes (can be slow if you're using e.g. NFS or Ceph)
|
||||||
for i in {1..60}; do
|
for i in {1..240}; do
|
||||||
if nc -vz $host $port ; then
|
if nc -vz $host $port ; then
|
||||||
synced=true
|
synced=true
|
||||||
break
|
break
|
||||||
|
|
|
@ -61,6 +61,7 @@ objectClass: posixGroup
|
||||||
gidNumber: 10003
|
gidNumber: 10003
|
||||||
cn: office
|
cn: office
|
||||||
uniqueMember: uid=ctdalek,ou=People,dc=csclub,dc=internal
|
uniqueMember: uid=ctdalek,ou=People,dc=csclub,dc=internal
|
||||||
|
uniqueMember: uid=office1,ou=People,dc=csclub,dc=internal
|
||||||
|
|
||||||
dn: cn=src,ou=Group,dc=csclub,dc=internal
|
dn: cn=src,ou=Group,dc=csclub,dc=internal
|
||||||
objectClass: top
|
objectClass: top
|
||||||
|
@ -160,3 +161,28 @@ objectClass: posixGroup
|
||||||
cn: exec
|
cn: exec
|
||||||
gidNumber: 10013
|
gidNumber: 10013
|
||||||
uniqueMember: uid=exec1,ou=People,dc=csclub,dc=internal
|
uniqueMember: uid=exec1,ou=People,dc=csclub,dc=internal
|
||||||
|
|
||||||
|
dn: uid=office1,ou=People,dc=csclub,dc=internal
|
||||||
|
cn: Office One
|
||||||
|
givenName: Office
|
||||||
|
sn: One
|
||||||
|
userPassword: {SASL}office1@CSCLUB.INTERNAL
|
||||||
|
loginShell: /bin/bash
|
||||||
|
homeDirectory: /users/office1
|
||||||
|
uid: office1
|
||||||
|
uidNumber: 20004
|
||||||
|
gidNumber: 20004
|
||||||
|
objectClass: top
|
||||||
|
objectClass: account
|
||||||
|
objectClass: posixAccount
|
||||||
|
objectClass: shadowAccount
|
||||||
|
objectClass: member
|
||||||
|
program: MAT/Mathematics Computer Science
|
||||||
|
term: f2021
|
||||||
|
|
||||||
|
dn: cn=office1,ou=Group,dc=csclub,dc=internal
|
||||||
|
objectClass: top
|
||||||
|
objectClass: group
|
||||||
|
objectClass: posixGroup
|
||||||
|
cn: office1
|
||||||
|
gidNumber: 20004
|
||||||
|
|
|
@ -78,15 +78,10 @@ def register_services(app):
|
||||||
cfg = Config(config_file)
|
cfg = Config(config_file)
|
||||||
component.provideUtility(cfg, IConfig)
|
component.provideUtility(cfg, IConfig)
|
||||||
|
|
||||||
# KerberosService
|
|
||||||
hostname = socket.gethostname()
|
hostname = socket.gethostname()
|
||||||
fqdn = socket.getfqdn()
|
|
||||||
# Only ceod_admin_host has the ceod/admin key in its keytab
|
# KerberosService
|
||||||
if hostname == cfg.get('ceod_admin_host'):
|
krb_srv = KerberosService()
|
||||||
principal = cfg.get('ldap_admin_principal')
|
|
||||||
else:
|
|
||||||
principal = f'ceod/{fqdn}'
|
|
||||||
krb_srv = KerberosService(principal)
|
|
||||||
component.provideUtility(krb_srv, IKerberosService)
|
component.provideUtility(krb_srv, IKerberosService)
|
||||||
|
|
||||||
# LDAPService
|
# LDAPService
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from flask import Blueprint, request, json
|
from flask import Blueprint, g, json, request
|
||||||
from zope import component
|
from zope import component
|
||||||
|
|
||||||
from .utils import authz_restrict_to_staff, authz_restrict_to_syscom, \
|
from .utils import authz_restrict_to_staff, authz_restrict_to_syscom, \
|
||||||
|
@ -28,6 +28,10 @@ def create_user():
|
||||||
if not body.get(attr):
|
if not body.get(attr):
|
||||||
raise BadRequest(f"Attribute '{attr}' is missing or empty")
|
raise BadRequest(f"Attribute '{attr}' is missing or empty")
|
||||||
|
|
||||||
|
# We need to use the admin creds here because office members may not
|
||||||
|
# directly create new LDAP records.
|
||||||
|
g.need_admin_creds = True
|
||||||
|
|
||||||
txn = AddMemberTransaction(
|
txn = AddMemberTransaction(
|
||||||
uid=body['uid'],
|
uid=body['uid'],
|
||||||
cn=body['cn'],
|
cn=body['cn'],
|
||||||
|
@ -82,6 +86,11 @@ def renew_user(username: str):
|
||||||
if (terms and non_member_terms) or not (terms or non_member_terms):
|
if (terms and non_member_terms) or not (terms or non_member_terms):
|
||||||
raise BadRequest('Must specify either terms or non-member terms')
|
raise BadRequest('Must specify either terms or non-member terms')
|
||||||
|
|
||||||
|
# We need to use the admin creds here because office members should
|
||||||
|
# not be able to directly modify the shadowExpire field; this could
|
||||||
|
# prevent syscom members from logging into the machines.
|
||||||
|
g.need_admin_creds = True
|
||||||
|
|
||||||
ldap_srv = component.getUtility(ILDAPService)
|
ldap_srv = component.getUtility(ILDAPService)
|
||||||
user = ldap_srv.get_user(username)
|
user = ldap_srv.get_user(username)
|
||||||
if body.get('terms'):
|
if body.get('terms'):
|
||||||
|
|
|
@ -2,6 +2,7 @@ import os
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
import gssapi
|
||||||
from zope import component
|
from zope import component
|
||||||
from zope.interface import implementer
|
from zope.interface import implementer
|
||||||
|
|
||||||
|
@ -10,20 +11,53 @@ from ceo_common.interfaces import IKerberosService, IConfig
|
||||||
|
|
||||||
@implementer(IKerberosService)
|
@implementer(IKerberosService)
|
||||||
class KerberosService:
|
class KerberosService:
|
||||||
def __init__(
|
def __init__(self):
|
||||||
self,
|
|
||||||
admin_principal: str,
|
|
||||||
):
|
|
||||||
cfg = component.getUtility(IConfig)
|
cfg = component.getUtility(IConfig)
|
||||||
|
|
||||||
self.admin_principal = admin_principal
|
# For creating new members and renewing memberships, we use the
|
||||||
self.realm = cfg.get('ldap_sasl_realm')
|
# admin credentials
|
||||||
# We don't need a credentials cache because the client forwards
|
self.admin_principal = cfg.get('ldap_admin_principal')
|
||||||
# their credentials to us
|
self.admin_principal_name = gssapi.Name(self.admin_principal)
|
||||||
|
ccache_file = cfg.get('ldap_admin_principal_ccache')
|
||||||
|
self.admin_principal_ccache = 'FILE:' + ccache_file
|
||||||
|
self.admin_principal_store = {'ccache': self.admin_principal_ccache}
|
||||||
|
# For everything else, the clients forwards (delegates) their
|
||||||
|
# credentials to us. Set KRB5CCNAME to /dev/null to mitigate the
|
||||||
|
# risk of the admin creds getting accidentally used instead.
|
||||||
os.environ['KRB5CCNAME'] = 'FILE:/dev/null'
|
os.environ['KRB5CCNAME'] = 'FILE:/dev/null'
|
||||||
|
|
||||||
def _run(self, args: List[str]):
|
def _run(self, args: List[str], **kwargs):
|
||||||
subprocess.run(args, check=True)
|
subprocess.run(args, check=True, **kwargs)
|
||||||
|
|
||||||
|
def _kinit_admin_creds(self):
|
||||||
|
env = {'KRB5CCNAME': self.admin_principal_ccache}
|
||||||
|
self._run([
|
||||||
|
'kinit', '-k', '-p', self.admin_principal
|
||||||
|
], env=env)
|
||||||
|
|
||||||
|
def _get_admin_creds_token_impl(self) -> bytes:
|
||||||
|
creds = gssapi.Credentials(
|
||||||
|
usage='initiate', name=self.admin_principal_name,
|
||||||
|
store=self.admin_principal_store)
|
||||||
|
# this will raise a gssapi.raw.exceptions.ExpiredCredentialsError
|
||||||
|
# if the ticket has expired
|
||||||
|
creds.inquire()
|
||||||
|
return creds.export()
|
||||||
|
|
||||||
|
def get_admin_creds_token(self) -> bytes:
|
||||||
|
"""
|
||||||
|
Returns a serialized GSSAPI credential which can be used to
|
||||||
|
authenticate to the CSC LDAP server with administrative privileges.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return self._get_admin_creds_token_impl()
|
||||||
|
except gssapi.raw.misc.GSSError:
|
||||||
|
# Either the ccache file does not exist, or the ticket has
|
||||||
|
# expired.
|
||||||
|
# Run kinit again.
|
||||||
|
self._kinit_admin_creds()
|
||||||
|
# This time should work
|
||||||
|
return self._get_admin_creds_token_impl()
|
||||||
|
|
||||||
def addprinc(self, principal: str, password: str):
|
def addprinc(self, principal: str, password: str):
|
||||||
self._run([
|
self._run([
|
||||||
|
|
|
@ -10,7 +10,7 @@ from zope.interface import implementer
|
||||||
from ceo_common.errors import UserNotFoundError, GroupNotFoundError, \
|
from ceo_common.errors import UserNotFoundError, GroupNotFoundError, \
|
||||||
UserAlreadyExistsError, GroupAlreadyExistsError
|
UserAlreadyExistsError, GroupAlreadyExistsError
|
||||||
from ceo_common.interfaces import ILDAPService, IConfig, \
|
from ceo_common.interfaces import ILDAPService, IConfig, \
|
||||||
IUser, IGroup, IUWLDAPService
|
IUser, IGroup, IUWLDAPService, IKerberosService
|
||||||
from ceo_common.model import Term
|
from ceo_common.model import Term
|
||||||
import ceo_common.utils as ceo_common_utils
|
import ceo_common.utils as ceo_common_utils
|
||||||
from .User import User
|
from .User import User
|
||||||
|
@ -31,16 +31,26 @@ class LDAPService:
|
||||||
self.club_min_id = cfg.get('clubs_min_id')
|
self.club_min_id = cfg.get('clubs_min_id')
|
||||||
self.club_max_id = cfg.get('clubs_max_id')
|
self.club_max_id = cfg.get('clubs_max_id')
|
||||||
|
|
||||||
|
self.krb_srv = component.getUtility(IKerberosService)
|
||||||
|
|
||||||
def _get_ldap_conn(self) -> ldap3.Connection:
|
def _get_ldap_conn(self) -> ldap3.Connection:
|
||||||
if 'ldap_conn' in g:
|
if 'ldap_conn' in g:
|
||||||
return g.ldap_conn
|
return g.ldap_conn
|
||||||
kwargs = {'auto_bind': True, 'raise_exceptions': True}
|
kwargs = {'auto_bind': True, 'raise_exceptions': True}
|
||||||
if 'client_token' in g:
|
|
||||||
|
# Use GSSAPI authentication if creds are available
|
||||||
|
creds_token = None
|
||||||
|
if g.get('need_admin_creds', False):
|
||||||
|
creds_token = self.krb_srv.get_admin_creds_token()
|
||||||
|
elif 'client_token' in g:
|
||||||
|
creds_token = g.client_token
|
||||||
|
if creds_token is not None:
|
||||||
kwargs['authentication'] = ldap3.SASL
|
kwargs['authentication'] = ldap3.SASL
|
||||||
kwargs['sasl_mechanism'] = ldap3.KERBEROS
|
kwargs['sasl_mechanism'] = ldap3.KERBEROS
|
||||||
creds = gssapi.Credentials(token=g.client_token)
|
creds = gssapi.Credentials(token=creds_token)
|
||||||
# see https://github.com/cannatag/ldap3/blob/master/ldap3/protocol/sasl/kerberos.py
|
# see https://github.com/cannatag/ldap3/blob/master/ldap3/protocol/sasl/kerberos.py
|
||||||
kwargs['sasl_credentials'] = (None, None, creds)
|
kwargs['sasl_credentials'] = (None, None, creds)
|
||||||
|
|
||||||
conn = ldap3.Connection(self.ldap_server_url, **kwargs)
|
conn = ldap3.Connection(self.ldap_server_url, **kwargs)
|
||||||
# cache the connection for a single request
|
# cache the connection for a single request
|
||||||
g.ldap_conn = conn
|
g.ldap_conn = conn
|
||||||
|
|
|
@ -17,6 +17,7 @@ port = 9987
|
||||||
|
|
||||||
[ldap]
|
[ldap]
|
||||||
admin_principal = ceod/admin
|
admin_principal = ceod/admin
|
||||||
|
admin_principal_ccache = /run/ceod/admin_ccache
|
||||||
server_url = ldaps://auth1.csclub.uwaterloo.ca
|
server_url = ldaps://auth1.csclub.uwaterloo.ca
|
||||||
sasl_realm = CSCLUB.UWATERLOO.CA
|
sasl_realm = CSCLUB.UWATERLOO.CA
|
||||||
users_base = ou=People,dc=csclub,dc=uwaterloo,dc=ca
|
users_base = ou=People,dc=csclub,dc=uwaterloo,dc=ca
|
||||||
|
|
|
@ -22,7 +22,7 @@ def test_cloud_account_activate(cli_setup, mock_cloud_server, new_user, cfg):
|
||||||
assert result.output == expected
|
assert result.output == expected
|
||||||
|
|
||||||
|
|
||||||
def test_cloud_accounts_purge(cli_setup, mock_cloud_server):
|
def test_cloud_accounts_purge(cli_setup, mock_cloud_server, mock_harbor_server):
|
||||||
mock_cloud_server.clear()
|
mock_cloud_server.clear()
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import ldap3
|
import ldap3
|
||||||
|
@ -84,7 +87,7 @@ def test_api_create_user(cfg, create_user_resp, mock_mail_server):
|
||||||
def test_api_next_uid(cfg, client, create_user_result):
|
def test_api_next_uid(cfg, client, create_user_result):
|
||||||
min_uid = cfg.get('members_min_id')
|
min_uid = cfg.get('members_min_id')
|
||||||
_, data = client.post('/api/members', json={
|
_, data = client.post('/api/members', json={
|
||||||
'uid': 'test_2',
|
'uid': 'test2',
|
||||||
'cn': 'Test Two',
|
'cn': 'Test Two',
|
||||||
'given_name': 'Test',
|
'given_name': 'Test',
|
||||||
'sn': 'Two',
|
'sn': 'Two',
|
||||||
|
@ -97,7 +100,7 @@ def test_api_next_uid(cfg, client, create_user_result):
|
||||||
assert result['uid_number'] == min_uid + 1
|
assert result['uid_number'] == min_uid + 1
|
||||||
assert result['gid_number'] == min_uid + 1
|
assert result['gid_number'] == min_uid + 1
|
||||||
finally:
|
finally:
|
||||||
client.delete('/api/members/test_2')
|
client.delete('/api/members/test2')
|
||||||
|
|
||||||
|
|
||||||
def test_api_get_user(cfg, client, create_user_result):
|
def test_api_get_user(cfg, client, create_user_result):
|
||||||
|
@ -321,3 +324,44 @@ def test_expire_syscom_member(client, new_user, syscom_group, g_admin_ctx, ldap_
|
||||||
assert data == []
|
assert data == []
|
||||||
else:
|
else:
|
||||||
assert data == [uid]
|
assert data == [uid]
|
||||||
|
|
||||||
|
|
||||||
|
def test_office_member(cfg, client):
|
||||||
|
admin_principal = cfg.get('ldap_admin_principal')
|
||||||
|
ccache_file = cfg.get('ldap_admin_principal_ccache')
|
||||||
|
if os.path.isfile(ccache_file):
|
||||||
|
os.unlink(ccache_file)
|
||||||
|
body = {
|
||||||
|
'uid': 'test3',
|
||||||
|
'cn': 'Test Three',
|
||||||
|
'given_name': 'Test',
|
||||||
|
'sn': 'Three',
|
||||||
|
'program': 'Math',
|
||||||
|
'terms': ['w2022'],
|
||||||
|
'forwarding_addresses': ['test3@uwaterloo.internal'],
|
||||||
|
}
|
||||||
|
status, data = client.post('/api/members', json=body, principal='office1')
|
||||||
|
assert status == 200 and data[-1]['status'] == 'completed'
|
||||||
|
|
||||||
|
# Make sure new admin creds were obtained
|
||||||
|
assert os.path.isfile(ccache_file)
|
||||||
|
os.unlink(ccache_file)
|
||||||
|
|
||||||
|
# Obtain new creds which expire
|
||||||
|
subprocess.run([
|
||||||
|
'kinit', '-k', '-p', admin_principal, '-l', '1'
|
||||||
|
], check=True, env={'KRB5CCNAME': 'FILE:' + ccache_file})
|
||||||
|
old_mtime = os.stat(ccache_file).st_mtime
|
||||||
|
# Wait for the ticket to expire
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
status, _ = client.post(
|
||||||
|
'/api/members/test3/renew',
|
||||||
|
json={'terms': ['s2022']}, principal='office1')
|
||||||
|
assert status == 200
|
||||||
|
|
||||||
|
# Make sure new admin creds were obtained
|
||||||
|
assert os.stat(ccache_file).st_mtime > old_mtime
|
||||||
|
|
||||||
|
status, _ = client.delete('/api/members/test3')
|
||||||
|
assert status == 200
|
||||||
|
|
|
@ -14,6 +14,7 @@ port = 9987
|
||||||
|
|
||||||
[ldap]
|
[ldap]
|
||||||
admin_principal = ceod/admin
|
admin_principal = ceod/admin
|
||||||
|
admin_principal_ccache = /run/ceod/admin_ccache
|
||||||
server_url = ldap://auth1.csclub.internal
|
server_url = ldap://auth1.csclub.internal
|
||||||
sasl_realm = CSCLUB.INTERNAL
|
sasl_realm = CSCLUB.INTERNAL
|
||||||
users_base = ou=People,dc=csclub,dc=internal
|
users_base = ou=People,dc=csclub,dc=internal
|
||||||
|
|
|
@ -14,6 +14,7 @@ port = 9988
|
||||||
|
|
||||||
[ldap]
|
[ldap]
|
||||||
admin_principal = ceod/admin
|
admin_principal = ceod/admin
|
||||||
|
admin_principal_ccache = /run/ceod/admin_ccache
|
||||||
server_url = ldap://auth1.csclub.internal
|
server_url = ldap://auth1.csclub.internal
|
||||||
sasl_realm = CSCLUB.INTERNAL
|
sasl_realm = CSCLUB.INTERNAL
|
||||||
users_base = ou=TestPeople,dc=csclub,dc=internal
|
users_base = ou=TestPeople,dc=csclub,dc=internal
|
||||||
|
|
|
@ -76,12 +76,11 @@ def krb_srv(cfg):
|
||||||
# TODO: create temporary Kerberos database using kdb5_util.
|
# TODO: create temporary Kerberos database using kdb5_util.
|
||||||
# We need to be root to read the keytab
|
# We need to be root to read the keytab
|
||||||
assert os.geteuid() == 0
|
assert os.geteuid() == 0
|
||||||
# this dance again... ugh
|
# Delete the admin creds file, if it exists
|
||||||
if socket.gethostname() == cfg.get('ceod_admin_host'):
|
ccache_file = cfg.get('ldap_admin_principal_ccache')
|
||||||
principal = 'ceod/admin'
|
if os.path.isfile(ccache_file):
|
||||||
else:
|
os.unlink(ccache_file)
|
||||||
principal = 'ceod/' + socket.getfqdn()
|
krb = KerberosService()
|
||||||
krb = KerberosService(principal)
|
|
||||||
component.getGlobalSiteManager().registerUtility(krb, IKerberosService)
|
component.getGlobalSiteManager().registerUtility(krb, IKerberosService)
|
||||||
|
|
||||||
delete_test_princs(krb)
|
delete_test_princs(krb)
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
import ceod.utils as ceod_utils
|
import ceod.utils as ceod_utils
|
||||||
import contextlib
|
import contextlib
|
||||||
import os
|
import os
|
||||||
import grp
|
|
||||||
import pwd
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from subprocess import DEVNULL
|
from subprocess import DEVNULL
|
||||||
import tempfile
|
import tempfile
|
||||||
|
@ -60,12 +58,5 @@ def gen_password_mock_ctx():
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def mocks_for_create_user_ctx():
|
def mocks_for_create_user_ctx():
|
||||||
with gen_password_mock_ctx(), \
|
with gen_password_mock_ctx():
|
||||||
patch.object(pwd, 'getpwuid') as getpwuid_mock, \
|
|
||||||
patch.object(grp, 'getgrgid') as getgrgid_mock:
|
|
||||||
# Normally, if getpwuid or getgrgid do *not* raise a KeyError,
|
|
||||||
# then LDAPService will skip that UID. Therefore, by raising a
|
|
||||||
# KeyError, we are making sure that the UID will *not* be skipped.
|
|
||||||
getpwuid_mock.side_effect = KeyError()
|
|
||||||
getgrgid_mock.side_effect = KeyError()
|
|
||||||
yield
|
yield
|
||||||
|
|
Loading…
Reference in New Issue