use admin GSSAPI creds for some API endpoints
continuous-integration/drone/pr Build is failing Details

This commit is contained in:
Max Erenberg 2022-03-12 14:34:26 -05:00
parent af4e342f3c
commit 924a4062b5
13 changed files with 157 additions and 40 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -60,12 +60,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