Skip to content

Conversation

ds-04
Copy link
Contributor

@ds-04 ds-04 commented Jun 6, 2025

This PR adds the project_openldap Coldfront plugin.

A comprehensive README.md is provided both in the main plugin directory and the management command directory.

Also included are mermaid diagrams (inside main plugin README.md and separate file in the management command directory). There are two diagrams one showing OpenLDAP DIT and the other showing the main syncer function (management command related).

Management commands:

  • A management setup checker command
  • A syncer command for when slapd connectivity is lost


#### GID:

- **NOTE: A starting GID number to increment posixgroup GIDs is required. Consider this carefully before going into project to avoid clashes or problems.**
Copy link

@simonLeary42 simonLeary42 Jun 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are there any protections against clashes? could it just check if a GID already exists and step over it?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using 8000 as the start GID, this is project 11 (pk=11), so 8000+11 is the resultant gidNumber.

I guess stepping over wouldn't work

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no protection at the moment ... so say your site might expect 3000 projects (overestimate) within the coldfront instance's lifetime (which is possibly directly coupled to a resource's lifetime .. eg. an HPC cluster that last 5-7 years) ... then you'd have to leave that contiguous block of 3k

  • perhaps this will be a future enhancement
  • currently at our site we have GIDs spaced apart by a large margin

a concern with non-contiguous might be harder to fix if it goes awry (or at least think about the state as an admin under pressure) ... e.g. in a situation where you require a re-sync or something like that .. also contiguous is easy for operators ... project 49 will have START GID + 49 ...

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it could also just be an error in the sync script

Copy link

@simonLeary42 simonLeary42 Aug 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To address this I just enabled the slapo-unique openldap overlay/module, which will give the sync script an error whenever it tries to assign a duplicate uidNumber/gidNumber/uid.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dn: cn=module{0},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: unique

ldapmodify -Y EXTERNAL -H ldapi:/// -f ~/load-unique.ldif

dn: olcOverlay=unique,olcDatabase={1}mdb,cn=config
objectClass: olcOverlayConfig
objectClass: olcUniqueConfig
olcOverlay: unique
olcUniqueAttribute: uid
olcUniqueAttribute: uidNumber
olcUniqueAttribute: gidNumber

ldapmodify -Y EXTERNAL -H ldapi:/// -f ~/configure-unique.ldif


The aforemention signals trigger functions in tasks.py, these inturn use functions in ``utils.py`` to accomplish the action required. Examples are:

- create a per project OU and per project posixgroup within a Projects OU - **new project**
Copy link

@simonLeary42 simonLeary42 Jun 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's the motivation for creating a per-project OU?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flexibility and further demarcation ... im thinking about adding a project's allocations underneath the per project OU - though that is just one approach for an allocation_openldap plugin

@ds-04 ds-04 force-pushed the project_openldap branch 3 times, most recently from 0c82b76 to 50fa30a Compare June 12, 2025 08:32
Copy link
Contributor

@Eric-Butcher Eric-Butcher left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a lot of really great content!

I left comments mostly concerning unhandled error cases, code maintaiability, spelling, and ways to structure the logic more in line with Django's conventions.

One of the other maintainers will be coming in (hopefully) soon with a more macro-level review concering functionality testing and checking to make sure this works well within coldfront as a whole. If you have any questions please feel free to @ me and I will get back to you as soon as I can!


This plugin makes use of django signals within Coldfront to push project information to OpenLDAP. The main motivation for this is so projects and their members can be represented inside a _PosixGroup_, with usage of _memberUid_ (for members). Having this information in OpenLDAP facilitates the usage of filesystem quotas and other activities on a project basis.

**The motivation for using this plugin, is that you want Coldfront and it's WebUI to be the source of truth**. This might be in constrast to another operating modality where information is [generally] imported from another system or IdP into Coldfront.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"contrast" not "constrast"

**The motivation for using this plugin, is that you want Coldfront and it's WebUI to be the source of truth**. This might be in constrast to another operating modality where information is [generally] imported from another system or IdP into Coldfront.
- You will still need some means of creating/registering users in your OpenLDAP, e.g. a user registration portal - that is not covered/provided here. If you are just testing, you might add these to the DIT with ldifs.

- **NOTE: The plugin doesn't write a Coldfront project's allocation(s) into OpenLDAP. It is expected a seperate plugin will do this.**
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"separate" not "seperate"


#### actions

The aforemention signals trigger functions in tasks.py, these inturn use functions in ``utils.py`` to accomplish the action required. Examples are:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"aforementioned" not "aforemention"
"in turn" not "inturn"


- Projects get created in the Projects OU. A per project OU and a per project posixgroup is created.
- Per project OU's get moved to Archive OU or deleted on Coldfront WebUI archive action.
- **If no Archive OU** is setup by the site adminsitrator, then **projects** (per project OUs) are **deleted on archival**.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"administrator" not "adminsitrator"

Comment on lines +66 to +67
```mermaid
%%{init: {'theme': 'forest', 'themeVariables': { 'fontSize': '20px', 'fontFamily': 'Inter'}}}%%
Copy link
Contributor

@Eric-Butcher Eric-Butcher Jun 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love mermaid diagrams!

The only issue here is that the arrows are not being rendered on github's mermaid renderer. I think if you add 'darkMode': true to the theme variables that it will fix that. Otherwise might have to change the theme.

Reference I found: https://github.com/Gordonby/MermaidTheming/blob/ed362bee7950f9b3a84967a31551b2f7abebd664/README.md?plain=1#L61C1-L61C110

Comment on lines 168 to 186
def handle_project_removal_if_needed(project, project_ou_dn, sync=False):
if project.status_id not in [
PROJECT_STATUS_CHOICE_NEW,
PROJECT_STATUS_CHOICE_ACTIVE,
]:
# archive OU not defined, so remove this project
if PROJECT_OPENLDAP_REMOVE_PROJECT and not PROJECT_OPENLDAP_ARCHIVE_OU:
if not sync:
print(
f"{project_ou_dn} <<< WARNING WE EXPECTED THIS TO BE REMOVED IN OPENLDAP - PROJECT_OPENLDAP_ARCHIVE_OU NOT is set"
)
print("Sync is required to make this change, please supply: -s or --sync")
if sync:
try:
remove_project(project)
print(f"Removed inactive project {project.project_code} from OpenLDAP - SYNC is {sync}")
except Exception as e:
print(f"Exception removing {project.project_code}, DN: {project_ou_dn} in OpenLDAP: {e}")

Copy link
Contributor

@Eric-Butcher Eric-Butcher Jun 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider breaking down large functions like this to reduce complexity. You can also use the inversion of your conditionals and quick returns to make the code more readable and less nested. For example:

Suggested change
def handle_project_removal_if_needed(project, project_ou_dn, sync=False):
if project.status_id not in [
PROJECT_STATUS_CHOICE_NEW,
PROJECT_STATUS_CHOICE_ACTIVE,
]:
# archive OU not defined, so remove this project
if PROJECT_OPENLDAP_REMOVE_PROJECT and not PROJECT_OPENLDAP_ARCHIVE_OU:
if not sync:
print(
f"{project_ou_dn} <<< WARNING WE EXPECTED THIS TO BE REMOVED IN OPENLDAP - PROJECT_OPENLDAP_ARCHIVE_OU NOT is set"
)
print("Sync is required to make this change, please supply: -s or --sync")
if sync:
try:
remove_project(project)
print(f"Removed inactive project {project.project_code} from OpenLDAP - SYNC is {sync}")
except Exception as e:
print(f"Exception removing {project.project_code}, DN: {project_ou_dn} in OpenLDAP: {e}")
def handle_project_removal_if_needed(project, project_ou_dn, sync=False):
if project.status_id in [PROJECT_STATUS_CHOICE_NEW, PROJECT_STATUS_CHOICE_ACTIVE]:
return # inverted conditional and return quickly instead
# archive OU not defined, so remove this project
if not PROJECT_OPENLDAP_REMOVE_PROJECT and PROJECT_OPENLDAP_ARCHIVE_OU:
return # inverted conditional and return quickly instead
if not sync:
print(
f"{project_ou_dn} <<< WARNING WE EXPECTED THIS TO BE REMOVED IN OPENLDAP - PROJECT_OPENLDAP_ARCHIVE_OU NOT is set"
)
print("Sync is required to make this change, please supply: -s or --sync")
else:
try:
remove_project(project)
print(f"Removed inactive project {project.project_code} from OpenLDAP - SYNC is {sync}")
except Exception as e:
print(f"Exception removing {project.project_code}, DN: {project_ou_dn} in OpenLDAP: {e}")

Comment on lines 188 to 230
def handle_description_update(
project,
ldapsearch_project_result=False,
ldapsearch_project_result_archive=False,
project_dn="",
archive_dn="",
sync=False,
write_to_archive=False,
):
new_description = construct_project_posixgroup_description(project) # supply project_obj

if project.status_id in [PROJECT_STATUS_CHOICE_NEW, PROJECT_STATUS_CHOICE_ACTIVE]:
# fetch current description from project_dn
fetched_description = ldapsearch_get_project_description(project_dn)
if new_description == fetched_description:
print("Description is up-to-date.")
if new_description != fetched_description:
if sync:
update_project_posixgroup_in_openldap(project_dn, new_description, write=True)
print(f"{new_description}")
else:
# line up description output
print(f"OLD openldap_description is {fetched_description}")
print(f"NEW openldap_description will be {new_description}")
print("SYNC required to update OpenLDAP description")

if project.status_id in [PROJECT_STATUS_CHOICE_ARCHIVED]:
# fetch current description from archive DN
fetched_description = ldapsearch_get_project_description(archive_dn)
if new_description == fetched_description:
print("Description is up-to-date.")
if new_description != fetched_description:
if not sync:
# line up description output
print(f"OLD openldap_description is {fetched_description}")
print(f"NEW openldap_description will be {new_description}")
print("SYNC required to update OpenLDAP description")
if sync and not write_to_archive:
print("CANNOT Modify descrption in OpenLDAP for this archived project")
print("WRITE_TO_ARCHIVE is required to make changes, please supply: -z or --write_archive")
if sync and write_to_archive:
update_project_posixgroup_in_openldap(archive_dn, new_description, write=True)
print(f"{new_description}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of the deep nesting here is not necessary, pooling more information onto a single level of conditional can make the code more clear in this instance. Something similar to the following:

Suggested change
def handle_description_update(
project,
ldapsearch_project_result=False,
ldapsearch_project_result_archive=False,
project_dn="",
archive_dn="",
sync=False,
write_to_archive=False,
):
new_description = construct_project_posixgroup_description(project) # supply project_obj
if project.status_id in [PROJECT_STATUS_CHOICE_NEW, PROJECT_STATUS_CHOICE_ACTIVE]:
# fetch current description from project_dn
fetched_description = ldapsearch_get_project_description(project_dn)
if new_description == fetched_description:
print("Description is up-to-date.")
if new_description != fetched_description:
if sync:
update_project_posixgroup_in_openldap(project_dn, new_description, write=True)
print(f"{new_description}")
else:
# line up description output
print(f"OLD openldap_description is {fetched_description}")
print(f"NEW openldap_description will be {new_description}")
print("SYNC required to update OpenLDAP description")
if project.status_id in [PROJECT_STATUS_CHOICE_ARCHIVED]:
# fetch current description from archive DN
fetched_description = ldapsearch_get_project_description(archive_dn)
if new_description == fetched_description:
print("Description is up-to-date.")
if new_description != fetched_description:
if not sync:
# line up description output
print(f"OLD openldap_description is {fetched_description}")
print(f"NEW openldap_description will be {new_description}")
print("SYNC required to update OpenLDAP description")
if sync and not write_to_archive:
print("CANNOT Modify descrption in OpenLDAP for this archived project")
print("WRITE_TO_ARCHIVE is required to make changes, please supply: -z or --write_archive")
if sync and write_to_archive:
update_project_posixgroup_in_openldap(archive_dn, new_description, write=True)
print(f"{new_description}")
def handle_description_update(
project,
ldapsearch_project_result=False,
ldapsearch_project_result_archive=False,
project_dn="",
archive_dn="",
sync=False,
write_to_archive=False,
):
project_status_is_current: bool = project.status_id in [PROJECT_STATUS_CHOICE_NEW,PROJECT_STATUS_CHOICE_ACTIVE]
project_status_is_archived: bool = project.status_id in [PROJECT_STATUS_CHOICE_ARCHIVED]
if not project_status_is_current or project_status_is_archived
return
fetched_description = ldapsearch_get_project_description(project_dn)
new_description = construct_project_posixgroup_description(project) # supply project_obj
should_update: bool = sync and (project_status_is_current or (project_status_is_archived and write_to_archive))
if new_description == fetched_description:
print("Description is up-to-date.")
elif should_update:
update_project_posixgroup_in_openldap(project_dn, new_description, write=True)
print(f"{new_description}")
elif project_status_is_archived and sync:
print("CANNOT Modify description in OpenLDAP for this archived project")
print("WRITE_TO_ARCHIVE is required to make changes, please supply: -z or --write_archive")
elif project_status_is_current or project_status_is_archived:
print(f"OLD openldap_description is {fetched_description}")
print(f"NEW openldap_description will be {new_description}")
print("SYNC required to update OpenLDAP description")

there might be more that can be done with this function still but I think I got the idea across.

If you have never come across the concept look up 'never nesting'. This will help to make the code more understandable so that whoever is updating it in the future (probably you in two months) has an easier time. This also makes it easier for maintainers to review as it makes the control flow and the intentions of the code clearer.

There are other functions in this file that this applies to as well.

Comment on lines 240 to 245
def local_get_openldap_members(dn):
entries = ldapsearch_get_project_memberuids(dn)
members = []
for entry in entries:
members.extend(entry.memberUid.values)
return tuple(members)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop could run into a NoneType exception because of ldapsearch_get_project_memberuids. Either add a guard clause if you get None returned to you or modify ldapsearch_get_project_memberuids to give [] when it reaches an exception.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will action good spot

Comment on lines 271 to 272
sys.exit(1)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use django's command error unless you have a good reason to not have django handle exiting this command.

Comment on lines 428 to 429
sys.exit(1)
# 2) --- END ---
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use django's command error unless you have a good reason to not have django handle exiting this command.

@ds-04 ds-04 force-pushed the project_openldap branch 2 times, most recently from f30dc27 to 3c07f9c Compare June 26, 2025 16:01
@ds-04
Copy link
Contributor Author

ds-04 commented Jun 26, 2025

Two force pushes, first changes ... then again i got caught by ruff versioning in my local environment...

The changes address the vast majority of the comments and improvements flagged


These issues were intentionally NOT addressed at this point:

  • project_openldap_check_setup.py - env var

  • project_openldap_check_setup.py - not isinstance(ldapsearch_check_project_ou_result, Exception)

  • project_openldap_check_setup.py - root logger

  • utils.py - logger.error("Project OU: DN to write...") etc. - own function

  • utils.py try statement

  • project_openldap_sync.py -

Consider breaking down large functions like this to reduce complexity...

  • project_openldap_sync.py -

Some of the deep nesting here is not necessary...

  • README.md (both) - mermaid diags - theme - i think it renders ok... but it can be changed in future

They are all valid and good points ... I'm just a bit concerned about trying to cram it all in right now.

@Eric-Butcher thanks for the expert review, it is appreciated


@Eric-Butcher please can you cast you eye over the sync management command in particular, post these changes 26.06.25

thanks

Comment on lines +513 to +514
self.stdout.write("ERROR: Unrecognized project status - HALTING")
raise CommandError
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can put the error message right in the CommandError and have Django take care of it for you.

Suggested change
self.stdout.write("ERROR: Unrecognized project status - HALTING")
raise CommandError
raise CommandError("Unrecognized project status - HALTING")

Comment on lines +324 to +328
if not ldapsearch_project_result and not ldapsearch_project_result_archive:
self.stdout.write(
"WARNING: sync_members (method) ldapsearch are both False for project OU and archive OU for project: {project.project_code}"
)
raise CommandError
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I missed this before. Is this supposed to be an f-string? You can also put the error message right inside the CommandError.

Suggested change
if not ldapsearch_project_result and not ldapsearch_project_result_archive:
self.stdout.write(
"WARNING: sync_members (method) ldapsearch are both False for project OU and archive OU for project: {project.project_code}"
)
raise CommandError
if not ldapsearch_project_result and not ldapsearch_project_result_archive:
raise CommandError(f"sync_members (method) ldapsearch are both False for project OU and archive OU for project: {project.project_code}")

@Eric-Butcher
Copy link
Contributor

I just checked the sync command file again and except for a couple things, I do not have anything new to mention. Everything looks a lot nicer!

Copy link
Member

@aebruno aebruno left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ds-04 Can you resolve the conflict and force push? Thanks!

@ds-04 ds-04 force-pushed the project_openldap branch from 3c07f9c to 9cf17f2 Compare July 2, 2025 06:14
Signed-off-by: David Simpson <>
@ds-04 ds-04 force-pushed the project_openldap branch from 9cf17f2 to 73e2020 Compare July 2, 2025 06:58
@ds-04
Copy link
Contributor Author

ds-04 commented Jul 2, 2025

thanks .. rectified, please double check files changed listing before merge

@aebruno aebruno merged commit 9df349d into ubccr:main Jul 2, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants