Skip to content

Commit a143c11

Browse files
authored
start of work to add flux python pam auth (#41)
* start of work to add flux python pam auth I have added pam auth, and next need to figure out how to return the username and have the job submit by the user Signed-off-by: vsoch <[email protected]>
1 parent ee26737 commit a143c11

File tree

36 files changed

+579
-152
lines changed

36 files changed

+579
-152
lines changed

.devcontainer/Dockerfile

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
FROM fluxrm/flux-sched:focal
1+
FROM ghcr.io/rse-ops/accounting:app-latest
22

33
LABEL maintainer="Vanessasaurus <@vsoch>"
44

55
# Pip not provided in this version
66
USER root
7-
RUN apt-get update && apt-get install -y python3-venv
7+
RUN apt-get update && apt-get install -y python3-venv systemctl
88
COPY ./requirements.txt /requirements.txt
99
COPY ./.github/dev-requirements.txt /dev-requirements.txt
1010
COPY ./docs/requirements.txt /docs-requirements.txt
@@ -18,11 +18,32 @@ RUN python3 -m pip install IPython && \
1818
python3 -m pip install -r /dev-requirements.txt && \
1919
python3 -m pip install -r /docs-requirements.txt
2020

21-
# Install isort and ensure on path
2221
RUN python3 -m venv /env && \
2322
. /env/bin/activate && \
2423
pip install -r /requirements.txt && \
2524
pip install -r /dev-requirements.txt && \
26-
pip install -r /docs-requirements.txt
25+
pip install -r /docs-requirements.txt && \
26+
# Only for development - don't add this to a production container
27+
sudo useradd -m -p $(openssl passwd '12345') "flux"
28+
29+
RUN mkdir -p /run/flux /var/lib/flux mkdir /etc/flux/system/cron.d /mnt/curve && \
30+
flux keygen /mnt/curve/curve.cert && \
31+
# This probably needs to be done as flux user?
32+
flux account create-db && \
33+
flux account add-bank root 1 && \
34+
flux account add-bank --parent-bank=root user_bank 1 && \
35+
# These need to be owned by flux
36+
chown -R flux /run/flux /var/lib/flux /mnt/curve && \
37+
# flux-imp needs setuid permission
38+
chmod u+s /usr/libexec/flux/flux-imp
39+
# flux account add-user --username=fluxuser --bank=user_bank
40+
41+
COPY ./example/multi-user/flux.service /etc/systemd/system/flux.service
42+
COPY ./example/multi-user/broker.toml /etc/flux/system/conf.d/broker.toml
43+
COPY ./example/multi-user/imp.toml /etc/flux/imp/conf.d/imp.toml
44+
45+
RUN chmod 4755 /usr/libexec/flux/flux-imp \
46+
&& chmod 0644 /etc/flux/imp/conf.d/imp.toml \
47+
&& chmod 0644 /etc/flux/system/conf.d/broker.toml
2748

2849
ENV PATH=/env/bin:${PATH}

.devcontainer/devcontainer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,6 @@
2424
],
2525
},
2626
},
27-
// Needed for git security feature
28-
"postStartCommand": "git config --global --add safe.directory /workspaces/flux-python-api"
27+
// Needed for git security feature, and flux config
28+
"postStartCommand": "git config --global --add safe.directory /workspaces/flux-python-api && flux R encode --hosts=$(hostname) > /etc/flux/system/R && sed -i 's@HOSTNAME@'$(hostname)'@' /etc/flux/system/conf.d/broker.toml && sudo service munge start"
2929
}

.github/workflows/tests.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,23 @@ jobs:
2929
run: pip install -r requirements.txt
3030
- name: Run tests
3131
run: |
32+
# Tests for the API with auth disabled
3233
flux start pytest -xs tests/test_api.py
34+
35+
# Tests for the API with single user auth
36+
export FLUX_REQUIRE_AUTH=true
3337
export TEST_AUTH=true
38+
export FLUX_USER=fluxuser
39+
export FLUX_TOKEN=12345
40+
flux start pytest -xs tests/test_api.py
41+
42+
# Tests for the API with multi-user auth, but fail because user not created
43+
unset FLUX_USER
44+
unset FLUX_TOKEN
45+
export TEST_PAM_AUTH=true
46+
export TEST_PAM_AUTH_FAIL=true
47+
export FLUX_ENABLE_PAM=true
3448
flux start pytest -xs tests/test_api.py
49+
50+
# TODO how to test pam in this mode?
51+
# We would need to start flux as flux and run tests as a user

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and **Merged pull requests**. Critical items to know are:
1414
The versions coincide with releases on pip. Only major versions will be released as tags on Github.
1515

1616
## [0.0.x](https://github.com/flux-framework/flux-restful-api/tree/main) (0.0.x)
17+
- Support for basic PAM authentication (0.0.11)
1718
- Fixing bug with launcher always being specified (0.0.1)
1819
- catching any errors on creation of fluxjob
1920
- Add support uvicorn workers (>1 needed to run >1 process with Flux)

Dockerfile

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ ARG host="0.0.0.0"
1111
ARG workers="1"
1212
LABEL maintainer="Vanessasaurus <@vsoch>"
1313

14+
ENV FLUX_USER=${user}
15+
ENV FLUX_TOKEN=${token}
16+
ENV FLUX_REQUIRE_AUTH=${use_auth}
17+
ENV PORT=${port}
18+
ENV HOST=${host}
19+
ENV WORKERS=${workers}
20+
1421
USER root
1522
RUN apt-get update
1623
COPY ./requirements.txt /requirements.txt
@@ -27,10 +34,4 @@ RUN python3 -m pip install -r /requirements.txt && \
2734

2835
WORKDIR /code
2936
COPY . /code
30-
ENV FLUX_USER=${user}
31-
ENV FLUX_TOKEN=${token}
32-
ENV FLUX_REQUIRE_AUTH=${use_auth}
33-
ENV PORT=${port}
34-
ENV HOST=${host}
35-
ENV WORKERS=${workers}
3637
ENTRYPOINT ["/code/entrypoint.sh"]

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.0.1
1+
0.0.11

app/core/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ class Settings(BaseSettings):
6767

6868
# These map to envars, e.g., FLUX_USER
6969
has_gpus: bool = get_bool_envar("FLUX_HAS_GPUS")
70+
enable_pam: bool = get_bool_envar("FLUX_ENABLE_PAM")
7071

7172
# Assume there is at least one node!
7273
flux_nodes: int = get_int_envar("FLUX_NUMBER_NODES", 1)

app/library/auth.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ def not_authenticated(detail="Incorrect user or token."):
2121

2222
def alert_auth():
2323
print("🍓 Require auth: %s" % settings.require_auth)
24+
print("🍓 PAM auth: %s" % settings.enable_pam)
2425
print(
2526
"🍓 Flux user: %s" % ("*" * len(settings.flux_user))
2627
if settings.flux_user
@@ -33,25 +34,53 @@ def alert_auth():
3334
)
3435

3536

37+
def check_pam_auth(credentials: HTTPBasicCredentials = Depends(security)):
38+
"""
39+
Check base64 encoded auth (this is HTTP Basic auth.)
40+
"""
41+
# Ensure we have pam installed
42+
try:
43+
import pam
44+
except ImportError:
45+
print("python-pam is required for PAM.")
46+
return
47+
48+
username = credentials.username.encode("utf8")
49+
password = credentials.password.encode("utf8")
50+
if pam.authenticate(username, password) is True:
51+
return credentials.username
52+
53+
3654
def check_auth(credentials: HTTPBasicCredentials = Depends(security)):
3755
"""
3856
Check base64 encoded auth (this is HTTP Basic auth.)
3957
"""
58+
# First try to authenticate with PAM, if allowed.
59+
if settings.enable_pam:
60+
print("🧾️ Checking PAM auth...")
61+
# Return the username if PAM authentication is successful
62+
username = check_pam_auth(credentials)
63+
if username:
64+
print("🧾️ Success!")
65+
return username
66+
67+
# If we get here, we require the flux user and token
4068
if not settings.flux_user or not settings.flux_token:
41-
return not_authenticated("Missing FLUX_USER and/or FLUX_TOKEN")
69+
return not_authenticated("Missing FLUX_USER and/or FLUX_TOKEN or pam headers")
70+
4271
current_username_bytes = credentials.username.encode("utf8")
4372
correct_username_bytes = bytes(settings.flux_user.encode("utf8"))
4473
is_correct_username = secrets.compare_digest(
4574
current_username_bytes, correct_username_bytes
4675
)
47-
current_password_bytes = credentials.password.encode("utf8")
4876

77+
current_password_bytes = credentials.password.encode("utf8")
4978
correct_password_bytes = bytes(settings.flux_token.encode("utf8"))
5079
is_correct_password = secrets.compare_digest(
5180
current_password_bytes, correct_password_bytes
5281
)
5382
if not (is_correct_username and is_correct_password):
54-
return not_authenticated()
83+
return not_authenticated("heree")
5584
return credentials.username
5685

5786

app/library/flux.py

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,41 @@
11
import json
22
import os
3+
import pwd
34
import re
45
import shlex
56
import time
67

78
import flux
89
import flux.job
910

11+
import app.library.terminal as terminal
1012
from app.core.config import settings
1113

14+
# Faux user environment (filtered set of application environment)
15+
# We could likely find a way to better do this, but likely the users won't have customized environments
16+
user_env = {
17+
"SHELL": "/bin/bash",
18+
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin",
19+
"XDG_RUNTIME_DIR": "/tmp/user/0",
20+
"DISPLAY": ":0",
21+
"COLORTERM": "truecolor",
22+
"SHLVL": "2",
23+
"DEBIAN_FRONTEND": "noninteractive",
24+
"MAKE_TERMERR": "/dev/pts/1",
25+
"LANG": "C.UTF-8",
26+
"TERM": "xterm-256color",
27+
}
28+
29+
30+
def submit_job(handle, jobspec, user):
31+
"""
32+
Handle to submit a job, either with flux job submit or on behalf of user.
33+
"""
34+
# We've enabled PAM auth
35+
if settings.enable_pam:
36+
return terminal.submit_job(jobspec, user)
37+
return flux.job.submit_async(handle, jobspec)
38+
1239

1340
def validate_submit_kwargs(kwargs, envars=None, runtime=None):
1441
"""
@@ -68,6 +95,7 @@ def prepare_job(kwargs, runtime=0, workdir=None, envars=None):
6895
command = kwargs["command"]
6996
if isinstance(command, str):
7097
command = shlex.split(command)
98+
7199
print(f"⭐️ Command being submit: {command}")
72100

73101
# Delete command from the kwargs (we added because is required and validated that way)
@@ -90,8 +118,14 @@ def prepare_job(kwargs, runtime=0, workdir=None, envars=None):
90118
# A duration of zero (the default) means unlimited
91119
fluxjob.duration = runtime
92120

121+
# If we are running as the user, we don't want the current (root) environment
122+
# This isn't perfect because it's artifically created, but it ensures we have paths
123+
if settings.enable_pam:
124+
environment = user_env
125+
else:
126+
environment = dict(os.environ)
127+
93128
# Additional envars in the payload?
94-
environment = dict(os.environ)
95129
environment.update(envars)
96130
fluxjob.environment = environment
97131
return fluxjob
@@ -131,12 +165,15 @@ def stream_job_output(jobid):
131165
pass
132166

133167

134-
def cancel_job(jobid):
168+
def cancel_job(jobid, user):
135169
"""
136170
Request a job to be cancelled by id.
137171
138172
Returns a message to the user and a return code.
139173
"""
174+
if settings.enable_pam:
175+
return terminal.cancel_job(jobid, user)
176+
140177
from app.main import app
141178

142179
try:
@@ -147,12 +184,16 @@ def cancel_job(jobid):
147184
return "Job is requested to cancel.", 200
148185

149186

150-
def get_job_output(jobid, delay=None):
187+
def get_job_output(jobid, user=None, delay=None):
151188
"""
152189
Given a jobid, get the output.
153190
154191
If there is a delay, we are requesting on demand, so we want to return early.
155192
"""
193+
# We've enabled PAM auth
194+
if settings.enable_pam:
195+
return terminal.get_job_output(jobid, user, delay=delay)
196+
156197
lines = []
157198
start = time.time()
158199
from app.main import app
@@ -171,38 +212,48 @@ def get_job_output(jobid, delay=None):
171212
return lines
172213

173214

174-
def list_jobs_detailed(limit=None, query=None):
215+
def list_jobs_detailed(user=None, limit=None, query=None):
175216
"""
176217
Get a detailed listing of jobs.
177218
"""
178-
listing = list_jobs()
219+
listing = list_jobs(user=user)
179220
ids = listing.get()["jobs"]
180221
jobs = {}
181222
for job in ids:
182-
183223
# Stop if a limit is defined and we have hit it!
184224
if limit is not None and len(jobs) >= limit:
185225
break
186226

187227
try:
188-
jobinfo = get_job(job["id"])
228+
jobinfo = get_job(job["id"], user=user)
189229

190230
# Best effort hack to do a query
191231
if query and not query_job(jobinfo, query):
192232
continue
233+
234+
# This will trigger a data table warning
235+
for needed in ["ranks", "expiration"]:
236+
if needed not in jobinfo:
237+
jobinfo[needed] = ""
238+
193239
jobs[job["id"]] = jobinfo
240+
194241
except Exception:
195242
pass
196243
return jobs
197244

198245

199-
def list_jobs():
246+
def list_jobs(user=None):
200247
"""
201248
Get a simple listing of jobs (just the ids)
202249
"""
203250
from app.main import app
204251

205-
return flux.job.job_list(app.handle)
252+
if user is None or not settings.enable_pam:
253+
return flux.job.job_list(app.handle)
254+
pw_record = pwd.getpwnam(user)
255+
user_uid = pw_record.pw_uid
256+
return flux.job.job_list(app.handle, userid=user_uid)
206257

207258

208259
def get_simple_job(jobid):
@@ -215,13 +266,17 @@ def get_simple_job(jobid):
215266
return json.loads(info.get_str())["job"]
216267

217268

218-
def get_job(jobid):
269+
def get_job(jobid, user):
219270
"""
220271
Get details for a job
221272
"""
222273
from app.main import app
223274

224-
payload = {"id": int(jobid), "attrs": ["all"]}
275+
jobid = flux.job.JobID(jobid)
276+
277+
payload = {"id": jobid, "attrs": ["all"]}
278+
if settings.enable_pam:
279+
payload["user"] = user
225280
rpc = flux.job.list.JobListIdRPC(app.handle, "job-list.list-id", payload)
226281
try:
227282
jobinfo = rpc.get()

0 commit comments

Comments
 (0)