Tyr common gitlab code violates security contrxt of python packages.
Steps to Reproduce
See this review from Claude
Review: extract dtaas-gitlab reusable GitLab client / user-provisioning library
Verdict: request changes.
The refactor itself is clean and behaviour-preserving. The packaging and CI
wiring around it is not: as written, merging this breaks PyPI publication of
dtaas-services, and the extracted code lands in a directory that no CI job
touches.
Verification performed
Everything below was reproduced locally against the branch head, not inferred
from diffs or from the SonarCloud summary.
| Check |
Result |
dtaas_services suite |
496 passed, 1 skipped, 5 errors (the 5 are tests/system_tests/ which shell out to docker, absent in this environment — unrelated to the change) |
dtaas_gitlab suite |
25 passed, 98% line coverage |
pylint dtaas_gitlab tests --rcfile=.pylintrc |
10.00/10 |
flake8 at the repo's --max-line-length=127 |
clean |
poetry build --format wheel in deploy/services/cli |
succeeds, but see Blocker 1 |
pip install of that wheel with the source tree absent |
fails, see Blocker 1 |
| PyPI state |
dtaas-services latest published is 0.4.1; dtaas-gitlab returns 404 (does not exist) |
No net test loss. The three PAT tests removed from
test_personal_token.py and the entire test_validators.py file reappear in
lib/tests/ with equivalent assertions. Worth stating explicitly, because a
diff showing 133 deleted test lines usually means something else.
Blockers
1. The path dependency is baked into the published wheel as an absolute local path
pyproject.toml gains:
dtaas-gitlab = {path = "../../../lib", develop = true}
Building the wheel produces this metadata:
Requires-Dist: dtaas-gitlab @ file:///<builder-working-directory>/lib
Two consequences, both confirmed:
-
PyPI will reject the upload. Warehouse refuses any requires_dist
entry containing a direct URL — 400 Bad Request … Invalid value for requires_dist. Error: Can't have direct dependency. The publish-pypi
job in platform-services-cli.yml fires on every push to
feature/distributed-demo, so the first merge turns that job red.
-
If it somehow shipped, it would be uninstallable. Reproduced by moving
the lib/ directory aside and installing the built wheel:
Processing /<builder-path>/lib (from dtaas_services)
ERROR: Could not install packages due to an OSError:
[Errno 2] No such file or directory: '/<builder-path>/lib'
The path is the build machine's path. It cannot resolve on any consumer
machine. dtaas-gitlab is not on PyPI either, so there is no fallback.
Note also that the in-tree version is already 0.4.3 while PyPI has 0.4.1,
so the publish job will genuinely attempt an upload rather than no-op.
Options, roughly in order of preference:
- Publish
dtaas-gitlab to PyPI first (its own workflow + trusted-publisher
config), then depend on it by version range. The path dep can stay for local
dev via a Poetry develop group or a [tool.poetry.group.dev] override,
but the [tool.poetry.dependencies] entry that ends up in metadata must be
a version constraint.
- Or keep the source shared but stop publishing it separately: keep the module
inside dtaas_services and let the CLI depend on dtaas-services.
- Whichever route, add a guard so a direct-URL
Requires-Dist can never reach
the publish step — twine check does not catch this class, so a grep of
the built metadata for @ file:// in CI is the cheap version.
2. lib/ has no CI at all
- No workflow has a
paths: entry matching lib/**. lib-ms.yml matches
servers/lib/**, which is the unrelated Node microservice.
lint-scripts.yml runs flake8 and pylint over
cli/ servers/ script/ deploy/services/cli/ deploy/workspace/ — lib/ is
not in the list.
platform-services-cli.yml filters on deploy/services/cli/**, so a change
confined to lib/ will not run the services tests even though services now
depends on that code at runtime. A regression introduced in
dtaas_gitlab.create_user merges with a fully green PR.
This is what the 0.0% coverage-on-new-code figure is reporting: the 25 tests
exist and pass locally, but nothing in CI executes them or uploads their
coverage. Meanwhile the moved lines disappear from the --cov=dtaas_services
measurement, so the project-level number drops for free.
Minimum fix: a python-lib.yml reusing python-reusable.yml with
working-directory: lib, plus adding lib/** to the path filters of
platform-services-cli.yml and lint-scripts.yml.
3. No lib/poetry.lock
git ls-files lib/ shows no lockfile, and nothing in .gitignore excludes
one. Every other Poetry package in the repo commits its lock. Without it the
python-gitlab transitive chain re-resolves on every install, so builds are
not reproducible and there is no hash-pinned record of what a release was
actually built against — the same supply-chain gap flagged on the logger
microservice previously.
Security-relevant findings
4. The library mutates global warning state permanently
client.py:
if not ssl_verify:
warnings.filterwarnings("ignore",
category=urllib3.exceptions.InsecureRequestWarning)
This was acceptable when it lived inside a single application entry point. As
a library function it is not: one call anywhere in a host process silences
InsecureRequestWarning for the entire interpreter, for the rest of its life,
including every unrelated urllib3 consumer in that process. Any future
importer of dtaas-gitlab inherits that suppression without asking for it.
The library should not touch process-global state. Either drop the
filterwarnings call and let the consuming application decide (the current
_api.py caller is exactly the right place for it), or scope it with
warnings.catch_warnings() around the actual request rather than at client
construction.
Related: ssl_verify is typed bool, but python-gitlab accepts a CA-bundle
path string in the same slot. Typing it bool | str costs nothing and keeps
the secure option — verify against the deployment's own CA — available.
Right now the only escape hatch a self-signed GitLab deployment has is
switching verification off entirely.
5. PAT scope and lifetime are hardcoded policy in a shared library
USER_PAT_NAME = "dtaas"
USER_PAT_SCOPES = ["api", "read_repository", "write_repository"]
_PAT_EXPIRY_DAYS = 365
api is effectively full read/write across everything the user can reach —
it subsumes both repository scopes listed alongside it. A 365-day
non-rotating token with that scope, issued automatically per provisioned user,
is a broad standing credential.
None of this is new, but this PR is the moment it stops being one
application's choice and becomes the default for every future consumer,
including the CLI in the follow-up PR. That makes it the right time to turn
scopes, expires_at/expiry_days, and name into caller-supplied
parameters with a least-privilege default, so a consumer that only needs
read_repository can ask for it.
Minor, same function: datetime.now() is naive local time while GitLab
interprets expires_at against the instance's own clock. datetime.now( timezone.utc) removes an off-by-one-day edge case on the boundary.
6. "Created" and "already exists" are indistinguishable to a careless caller
create_user returns (True, "", None) for HTTP 409. The docstring explains
it, and the in-repo caller handles it correctly, but for a public library API
a True that means "someone else's account already occupies this username"
is a footgun. Two things worth doing:
- Return an explicit status (enum or a third state) rather than encoding it in
a nullable id.
- Document loudly that on the 409 path the supplied password is not
applied. A caller could reasonably assume that after create_user returns
success, the credentials it passed are the live credentials for that
account. They are not, and the provisioning flow silently issues no PAT.
7. No py.typed marker
packages = [{include = "dtaas_gitlab"}] ships no py.typed, so once the
library is installed as a real wheel, type information is stripped for
downstream checkers. This is invisible today only because develop = true
puts the source on the path. Add py.typed and include it in the package
data — cheap, and dtaas_services runs pyright.
Lower severity
-
README documents signatures that don't exist.
create_user(gl, *, username, email, password, name=None) — there is no
name parameter; the implementation hardcodes "name": username.
create_user_pat(gl, user_id, username, ...) — the ... implies optional
parameters that also don't exist. For a package whose README is its API
contract, this should match the code exactly.
-
lib/ is an ambiguous home. The repo already has servers/lib (Node
microservice) and dtaas_services/pkg/lib/. A third top-level lib/
holding exactly one Python package invites confusion in path filters,
grep, and docs — as it already has in the CI filters above. Placing it at
lib/ matches the earlier design direction, so this is a naming nit
rather than a redirect, but a README line and clearer package naming
would help.
-
Docs not updated. docs/developer/codebase/dtaas-services.md still
shows a package-layout tree containing validators.py under
services/gitlab/, and docs/developer/codebase/publish-packages.md
still says the repo maintains "Two Poetry-based CLIs" with no mention of
a third publishable Python artifact or its release story.
-
Uncovered branches. users.py:61 (the non-409 GitlabCreateError
path — the actual failure branch of the error handler) and
validators.py:40. Both are one test each.
-
Packaging metadata. authors = ["Mohamed Abdulkarim"] omits the email
Poetry conventionally expects. There is no CHANGELOG or release note for
0.1.0, so a consumer pinning a version range has nothing to read.
What is good here
- The seam is drawn in the right place.
_api.py keeps env-var resolution and
URL derivation; the library takes an explicit URL and token and does no
environment, filesystem, or console I/O. That is exactly the contract the
README claims, and it holds under inspection.
- Behaviour is genuinely preserved.
_create_single_user delegates with the
same trimming, the same validation ordering, and the same
(success, error, user_id) contract; _create_user_and_pat's handling of
the user_id is None case is unchanged.
- Tests moved rather than vanished, and the new suite is slightly better
organised than what it replaced.
- Validation still runs before any API call, so malformed usernames and
emails never reach GitLab. That property survived the move intact.
@8ohamed, please make the necessary changes. Thanks.
Tyr common gitlab code violates security contrxt of python packages.
Steps to Reproduce
See this review from Claude
Review: extract
dtaas-gitlabreusable GitLab client / user-provisioning libraryVerdict: request changes.
The refactor itself is clean and behaviour-preserving. The packaging and CI
wiring around it is not: as written, merging this breaks PyPI publication of
dtaas-services, and the extracted code lands in a directory that no CI jobtouches.
Verification performed
Everything below was reproduced locally against the branch head, not inferred
from diffs or from the SonarCloud summary.
dtaas_servicessuitetests/system_tests/which shell out todocker, absent in this environment — unrelated to the change)dtaas_gitlabsuitepylint dtaas_gitlab tests --rcfile=.pylintrcflake8at the repo's--max-line-length=127poetry build --format wheelindeploy/services/clipip installof that wheel with the source tree absentdtaas-serviceslatest published is 0.4.1;dtaas-gitlabreturns 404 (does not exist)No net test loss. The three PAT tests removed from
test_personal_token.pyand the entiretest_validators.pyfile reappear inlib/tests/with equivalent assertions. Worth stating explicitly, because adiff showing 133 deleted test lines usually means something else.
Blockers
1. The path dependency is baked into the published wheel as an absolute local path
pyproject.tomlgains:Building the wheel produces this metadata:
Two consequences, both confirmed:
PyPI will reject the upload. Warehouse refuses any
requires_distentry containing a direct URL —
400 Bad Request … Invalid value for requires_dist. Error: Can't have direct dependency. Thepublish-pypijob in
platform-services-cli.ymlfires on every push tofeature/distributed-demo, so the first merge turns that job red.If it somehow shipped, it would be uninstallable. Reproduced by moving
the
lib/directory aside and installing the built wheel:The path is the build machine's path. It cannot resolve on any consumer
machine.
dtaas-gitlabis not on PyPI either, so there is no fallback.Note also that the in-tree version is already
0.4.3while PyPI has0.4.1,so the publish job will genuinely attempt an upload rather than no-op.
Options, roughly in order of preference:
dtaas-gitlabto PyPI first (its own workflow + trusted-publisherconfig), then depend on it by version range. The path dep can stay for local
dev via a Poetry
developgroup or a[tool.poetry.group.dev]override,but the
[tool.poetry.dependencies]entry that ends up in metadata must bea version constraint.
inside
dtaas_servicesand let the CLI depend ondtaas-services.Requires-Distcan never reachthe publish step —
twine checkdoes not catch this class, so a grep ofthe built metadata for
@ file://in CI is the cheap version.2.
lib/has no CI at allpaths:entry matchinglib/**.lib-ms.ymlmatchesservers/lib/**, which is the unrelated Node microservice.lint-scripts.ymlruns flake8 and pylint overcli/ servers/ script/ deploy/services/cli/ deploy/workspace/—lib/isnot in the list.
platform-services-cli.ymlfilters ondeploy/services/cli/**, so a changeconfined to
lib/will not run the services tests even though services nowdepends on that code at runtime. A regression introduced in
dtaas_gitlab.create_usermerges with a fully green PR.This is what the 0.0% coverage-on-new-code figure is reporting: the 25 tests
exist and pass locally, but nothing in CI executes them or uploads their
coverage. Meanwhile the moved lines disappear from the
--cov=dtaas_servicesmeasurement, so the project-level number drops for free.
Minimum fix: a
python-lib.ymlreusingpython-reusable.ymlwithworking-directory: lib, plus addinglib/**to the path filters ofplatform-services-cli.ymlandlint-scripts.yml.3. No
lib/poetry.lockgit ls-files lib/shows no lockfile, and nothing in.gitignoreexcludesone. Every other Poetry package in the repo commits its lock. Without it the
python-gitlabtransitive chain re-resolves on every install, so builds arenot reproducible and there is no hash-pinned record of what a release was
actually built against — the same supply-chain gap flagged on the logger
microservice previously.
Security-relevant findings
4. The library mutates global warning state permanently
client.py:This was acceptable when it lived inside a single application entry point. As
a library function it is not: one call anywhere in a host process silences
InsecureRequestWarningfor the entire interpreter, for the rest of its life,including every unrelated
urllib3consumer in that process. Any futureimporter of
dtaas-gitlabinherits that suppression without asking for it.The library should not touch process-global state. Either drop the
filterwarningscall and let the consuming application decide (the current_api.pycaller is exactly the right place for it), or scope it withwarnings.catch_warnings()around the actual request rather than at clientconstruction.
Related:
ssl_verifyis typedbool, butpython-gitlabaccepts a CA-bundlepath string in the same slot. Typing it
bool | strcosts nothing and keepsthe secure option — verify against the deployment's own CA — available.
Right now the only escape hatch a self-signed GitLab deployment has is
switching verification off entirely.
5. PAT scope and lifetime are hardcoded policy in a shared library
apiis effectively full read/write across everything the user can reach —it subsumes both repository scopes listed alongside it. A 365-day
non-rotating token with that scope, issued automatically per provisioned user,
is a broad standing credential.
None of this is new, but this PR is the moment it stops being one
application's choice and becomes the default for every future consumer,
including the CLI in the follow-up PR. That makes it the right time to turn
scopes,expires_at/expiry_days, andnameinto caller-suppliedparameters with a least-privilege default, so a consumer that only needs
read_repositorycan ask for it.Minor, same function:
datetime.now()is naive local time while GitLabinterprets
expires_atagainst the instance's own clock.datetime.now( timezone.utc)removes an off-by-one-day edge case on the boundary.6. "Created" and "already exists" are indistinguishable to a careless caller
create_userreturns(True, "", None)for HTTP 409. The docstring explainsit, and the in-repo caller handles it correctly, but for a public library API
a
Truethat means "someone else's account already occupies this username"is a footgun. Two things worth doing:
a nullable id.
applied. A caller could reasonably assume that after
create_userreturnssuccess, the credentials it passed are the live credentials for that
account. They are not, and the provisioning flow silently issues no PAT.
7. No
py.typedmarkerpackages = [{include = "dtaas_gitlab"}]ships nopy.typed, so once thelibrary is installed as a real wheel, type information is stripped for
downstream checkers. This is invisible today only because
develop = trueputs the source on the path. Add
py.typedand include it in the packagedata — cheap, and
dtaas_servicesruns pyright.Lower severity
README documents signatures that don't exist.
create_user(gl, *, username, email, password, name=None)— there is nonameparameter; the implementation hardcodes"name": username.create_user_pat(gl, user_id, username, ...)— the...implies optionalparameters that also don't exist. For a package whose README is its API
contract, this should match the code exactly.
lib/is an ambiguous home. The repo already hasservers/lib(Nodemicroservice) and
dtaas_services/pkg/lib/. A third top-levellib/holding exactly one Python package invites confusion in path filters,
grep, and docs — as it already has in the CI filters above. Placing it at
lib/matches the earlier design direction, so this is a naming nitrather than a redirect, but a
READMEline and clearer package namingwould help.
Docs not updated.
docs/developer/codebase/dtaas-services.mdstillshows a package-layout tree containing
validators.pyunderservices/gitlab/, anddocs/developer/codebase/publish-packages.mdstill says the repo maintains "Two Poetry-based CLIs" with no mention of
a third publishable Python artifact or its release story.
Uncovered branches.
users.py:61(the non-409GitlabCreateErrorpath — the actual failure branch of the error handler) and
validators.py:40. Both are one test each.Packaging metadata.
authors = ["Mohamed Abdulkarim"]omits the emailPoetry conventionally expects. There is no
CHANGELOGor release note for0.1.0, so a consumer pinning a version range has nothing to read.What is good here
_api.pykeeps env-var resolution andURL derivation; the library takes an explicit URL and token and does no
environment, filesystem, or console I/O. That is exactly the contract the
README claims, and it holds under inspection.
_create_single_userdelegates with thesame trimming, the same validation ordering, and the same
(success, error, user_id)contract;_create_user_and_pat's handling ofthe
user_id is Nonecase is unchanged.organised than what it replaced.
emails never reach GitLab. That property survived the move intact.
@8ohamed, please make the necessary changes. Thanks.