Skip to content

feat: add group management, role management, and project membership resources#53

Merged
Fodoj merged 14 commits intomainfrom
claude/add-openai-groups-terraform-QToWa
Feb 17, 2026
Merged

feat: add group management, role management, and project membership resources#53
Fodoj merged 14 commits intomainfrom
claude/add-openai-groups-terraform-QToWa

Conversation

@Fodoj
Copy link
Contributor

@Fodoj Fodoj commented Feb 5, 2026

#43

Summary

Comprehensive group and role management for the OpenAI Terraform provider. Groups are collections of users that can be assigned roles at the organization or project level.

New Resources (7)

Resource Purpose
openai_group Create/manage organization groups
openai_group_user Add a user to a group
openai_project_group Add a group to a project with role_ids
openai_project_user Add a user to a project with role_ids (changed from single role)
openai_organization_role Create custom org-level roles
openai_organization_group_role Assign an org role to a group
openai_organization_user_role Assign an org role to a user

New Data Sources (10)

Data Source Purpose
openai_role Look up an org role by name
openai_roles List all org roles
openai_group_roles List org roles assigned to a group
openai_group_user Look up a user in a group (by user_id or email)
openai_group_users List all users in a group
openai_project_role Look up a project role by name
openai_project_roles List all project roles
openai_project_group_roles List roles assigned to a group in a project
openai_organization_user_roles List org roles assigned to a user
openai_project_user_roles List roles assigned to a user in a project

Breaking Changes

  • openai_project_user: role (string) replaced with role_ids (set of role IDs)
  • openai_project_group: role_id (string) replaced with role_ids (set of role IDs)

Deleted (legacy/redundant)

  • openai_project_user_role — redundant with openai_project_user.role_ids
  • openai_project_group_role — redundant with openai_project_group.role_ids
  • resource_openai_user_role.go — legacy SDKv2, never registered
  • data_source_openai_user_role.go — legacy SDKv2, never registered

Type of change

  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update

How Has This Been Tested?

  • Manual testing against live OpenAI Admin API
    • Verified group CRUD (create, rename, delete)
    • Verified group user add/remove
    • Verified project group membership with multiple role assignments
    • Verified project user membership with multiple role assignments
    • Verified role assignment updates (add/remove roles in-place)
    • Verified all data sources return correct data

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • go build ./... passes
  • go vet ./... passes
  • gofmt clean

Add support for managing OpenAI Project Groups via Terraform. Groups are
collections of users synced from identity providers via SCIM.

New resources:
- openai_project_group: Manage group access to projects

New data sources:
- openai_project_group: Look up a specific group by ID or name
- openai_project_groups: List all groups in a project

The implementation follows the existing project_user pattern and uses
the OpenAI Admin API for all operations.

https://claude.ai/code/session_01DtgMTbRXemrjcVjdtfcDR1
@Fodoj Fodoj self-assigned this Feb 5, 2026
@coderabbitai
Copy link

coderabbitai bot commented Feb 5, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds comprehensive group and project-group functionality to the provider: new resources (group, group_user, project_group), multiple data sources (single and list variants, project roles, project group roles), API types, provider registrations, pagination-aware API interactions, examples, and generated documentation; also removes an older research doc.

Changes

Cohort / File(s) Summary
Provider registration & core types
internal/provider/provider.go, internal/provider/types_project_org.go
Registered new resources/data sources and added many API request/response types supporting groups, project groups, roles, and assignments.
Project group resource & data sources (implementation)
internal/provider/resource_openai_project_group.go, internal/provider/data_source_openai_project_group.go, internal/provider/data_source_openai_project_group_roles.go
Added ProjectGroup resource (Create/Read/Delete, Import) and data sources for single lookup, listing, and project-group role assignments; includes pagination, auth selection, URL handling, and Terraform state models.
Group resource & data sources (implementation)
internal/provider/resource_openai_group.go, internal/provider/data_source_openai_group.go
Added Group resource (CRUD + Import) and Group(s) data sources with paginated list/read flows, JSON mapping, and organization header support.
Group user resource & data sources (implementation)
internal/provider/resource_openai_group_user.go, internal/provider/data_source_openai_group_user.go
Added GroupUser resource (Create/Read/Delete, Import) and GroupUser(s) data sources; implements paginated lookups, membership endpoints, helper routines, and state population.
Project roles data sources (implementation)
internal/provider/data_source_openai_project_role.go, internal/provider/data_source_openai_project_group_roles.go
Added data sources to list project roles and project-group role assignments with pagination, mapping of role metadata and assignment details.
API client interactions & pagination
internal/provider/*_openai_*.go (multiple files)
Multiple data sources/resources implement HTTP calls to OpenAI Admin API, handle Admin/API key selection, optional OpenAI-Organization header, URL normalization, and cursor-based pagination.
Documentation
docs/resources/*.md, docs/data-sources/*.md, docs/data-sources/project_*.md
Added generated docs for all new resources and data sources (schemas, examples, auth notes, import guidance); deleted docs/research/openai_groups_api.md.
Examples
examples/resources/openai_group*, examples/resources/openai_group_user*, examples/resources/openai_project_group*, examples/data-sources/openai_group*, examples/data-sources/openai_group_user*, examples/data-sources/openai_group_users*, examples/data-sources/openai_groups*, examples/data-sources/openai_project_group*, examples/data-sources/openai_project_groups*, examples/data-sources/openai_project_roles*, examples/data-sources/openai_project_group_roles*
Added example Terraform configs, provider blocks, variables, and outputs demonstrating usage of new resources and data sources; many examples note the Admin API key requirement.
Deleted research doc
docs/research/openai_groups_api.md
Removed legacy research/documentation file describing the Groups API.

Sequence Diagram(s)

sequenceDiagram
    participant TF as Terraform
    participant Prov as OpenAI Provider
    participant API as OpenAI Admin API

    rect rgba(100, 150, 200, 0.5)
    note over TF,API: Create project group assignment
    TF->>Prov: Create ProjectGroup(project_id, group_id, role_id)
    Prov->>API: POST /v1/organization/projects/{project_id}/groups {group_id, role}
    API-->>Prov: 201 Created (assignment)
    Prov->>TF: Return state (id, group_id, role_id, created_at)
    end

    rect rgba(150, 100, 200, 0.5)
    note over TF,API: Read/List with pagination & matching
    TF->>Prov: Read DataSource(project_id [, group_id | group_name])
    Prov->>API: GET /v1/organization/projects/{project_id}/groups?limit=100[&after=...]
    loop paginate
      API-->>Prov: batch of groups (+pagination token)
      Prov->>Prov: filter for match OR aggregate lists
    end
    Prov->>TF: Return computed attributes (groups, ids, counts, owner/member lists)
    end

    rect rgba(200, 150, 100, 0.5)
    note over TF,API: Manage group users
    TF->>Prov: Create GroupUser(group_id, user_id)
    Prov->>API: POST /v1/organization/groups/{group_id}/users {user_id}
    API-->>Prov: 201 Created
    Prov->>API: GET /v1/organization/groups/{group_id}/users?limit=100 (populate details)
    API-->>Prov: user list
    Prov->>TF: Return state (id="group:user", user_name, email, role, added_at)
    end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐰 I hopped through code, docs, and API skiffs,
Paginated paths filled my tiny cliffs,
Groups and roles in neat little rows,
Admin keys whispered where authorization goes,
Hop, patch, repeat — the infra garden grows 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: add group management, role management, and project membership resources' accurately reflects the main purpose of this comprehensive PR adding multiple group, role, and project management resources and data sources.
Description check ✅ Passed The description is detailed and well-structured, covering summary, new resources/data sources in tables, breaking changes, deleted items, testing approach, and checklist. It follows the template structure with properly filled sections.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch claude/add-openai-groups-terraform-QToWa

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 11

🤖 Fix all issues with AI agents
In `@examples/resources/openai_project_group/variables.tf`:
- Around line 1-13: The openai_project resources are using hardcoded names
instead of the variables; update each resource "openai_project" (e.g., the dev
and prod project resources) to set name = var.dev_project_name for the
development resource and name = var.prod_project_name for the production
resource so the examples use the defined variables dev_project_name and
prod_project_name rather than literal strings.

In `@internal/provider/data_source_openai_project_group.go`:
- Line 365: The call to url.Parse(reqURL) currently ignores its error; change it
to capture and handle the error (e.g. parsedURL, err := url.Parse(reqURL)) and
return or log an appropriate error if err != nil to avoid nil-pointer usage
later; update any code using parsedURL afterwards to assume a non-nil parsedURL
only after the error check (referencing parsedURL and reqURL in the same block
where url.Parse is called).
- Line 139: The call to url.Parse currently ignores the error (parsedURL, _ :=
url.Parse(reqURL)); replace the blank identifier with an error variable, check
if err != nil and handle it appropriately (e.g., return the error upstream or
log and return a wrapped fmt.Errorf) to avoid using a nil parsedURL; update the
surrounding function (referencing parsedURL and reqURL) to propagate or handle
the parse failure consistently with the function's error-handling pattern.
- Around line 138-198: The pagination loop that searches for a project group
(the for foundGroup == nil { ... } block) currently uses defer
httpResp.Body.Close() inside the loop which leaks open bodies across iterations;
replace the deferred close with an explicit httpResp.Body.Close() call
immediately after you finish using the response (after decoding listResp and
after any error handling) so each page's body is closed before the next request;
ensure you still handle cases where httpResp or httpResp.Body may be nil and
that any early returns close the body when appropriate.
- Around line 364-424: The loop in data_source_openai_project_group.go defers
httpResp.Body.Close() inside the for loop which accumulates deferred calls;
instead, remove the defer and explicitly close httpResp.Body after you're done
with the response for each iteration (i.e., after checking StatusCode and after
json.NewDecoder(httpResp.Body).Decode(&listResp) and processing listResp.Data).
Also ensure you call httpResp.Body.Close() before any early return paths that
occur after httpClient.Do succeeded (for example before returning on non-200
status or decode errors) so the response body is always closed per iteration;
look for the variables/http calls named httpClient.Do, httpResp,
json.NewDecoder, listResp, cursor and the pagination loop to apply the changes.

In `@internal/provider/resource_openai_project_group.go`:
- Around line 150-160: The code silently ignores errors from io.ReadAll when
reading apiResp.Body; change the logic in the function handling the API response
(where apiResp, resp.Diagnostics, and ProjectGroupResponseFramework are used) to
read the body once into respBodyBytes and capture the error (respBodyBytes, err
:= io.ReadAll(apiResp.Body)); if err != nil call resp.Diagnostics.AddError with
a clear message including err.Error() and return; then use respBodyBytes for
both the API error branch (apiResp.Status) and JSON unmarshal (json.Unmarshal
into ProjectGroupResponseFramework), removing the second unchecked io.ReadAll.
- Line 116: The call to json.Marshal(reqMap) currently ignores its error; update
the code around reqBody, json.Marshal(reqMap) to capture the returned (reqBody,
err) and handle err (log and return the error or wrap it and propagate) so
marshaling failures aren't silenced; adjust subsequent code that uses reqBody
accordingly (in the function that builds the OpenAI project/group request in
resource_openai_project_group.go).
- Around line 142-146: Replace use of http.DefaultClient.Do(apiReq) with a
dedicated http.Client that sets a reasonable Timeout (e.g., 10s) and call
client.Do(apiReq) instead; update the code around the apiReq/apiResp handling
and ensure the "time" package is imported. Specifically, where apiResp, err :=
http.DefaultClient.Do(apiReq) appears in resource_openai_project_group.go,
create an http.Client variable with a Timeout and use that client's Do method so
the request cannot hang indefinitely.
- Line 202: The call to url.Parse(baseURL) ignores its error; change the
parsedURL, _ := url.Parse(baseURL) to capture the error (e.g. parsedURL, err :=
url.Parse(baseURL)) and handle it appropriately in the surrounding function
(return the error or wrap/log it) so that invalid baseURL values are detected;
update any callers or return signatures if needed to propagate the error from
this function where parsedURL is used (reference parsedURL, url.Parse, baseURL).
- Around line 285-322: The Delete method on ProjectGroupResource currently
swallows errors causing state drift; update Delete to validate the split ID
(from ProjectGroupResourceModel.ID) and append a diagnostic error to
resp.Diagnostics when the format is invalid instead of returning silently;
handle errors from http.NewRequest by appending a diagnostic and returning;
perform the HTTP call via http.DefaultClient.Do(apiReq), check for errors and
non-2xx status codes, read/close the response body, and append a detailed
diagnostic including status and body on failure so Terraform does not silently
remove state (use symbols Delete, ProjectGroupResource,
ProjectGroupResourceModel, apiReq, reqURL, apiKey, resp.Diagnostics).
- Around line 201-259: Defer inside the pagination loop leaves HTTP bodies open;
change the logic in the loop that uses apiResp and listResp (the block that
creates apiReq, calls http.DefaultClient.Do, and decodes into
ProjectGroupListResponse) to explicitly close apiResp.Body after you've finished
reading/decoding each page (e.g., read/decode, then call apiResp.Body.Close()
immediately before breaking/continuing), instead of using defer
apiResp.Body.Close(); ensure you still close the body on all error-return paths
and before each loop iteration advances the cursor or exits so no response
bodies accumulate.
🧹 Nitpick comments (4)
examples/data-sources/openai_project_group/variables.tf (1)

1-13: Variables are not referenced in the data-source.tf example.

These variables define project_id and group_id, but the corresponding data-source.tf in this PR uses hardcoded values ("proj-abc123", "group-xyz789") instead of var.project_id and var.group_id. Consider updating data-source.tf to use these variables for consistency and to demonstrate their purpose.

docs/data-sources/project_group.md (1)

56-60: Consider adding group_id and group_name to Read-Only outputs.

When looking up by group_name, the group_id becomes a useful output. Similarly, when looking up by group_id, the group_name is populated. Consider documenting these as Read-Only outputs for completeness:

 ### Read-Only

 - `id` (String) The ID of the resource (composite of project_id:group_id).
+- `group_id` (String) The ID of the group (useful when looking up by name).
+- `group_name` (String) The display name of the group (useful when looking up by ID).
 - `role` (String) The role of the group in the project (e.g., 'owner' or 'member').
 - `created_at` (Number) Timestamp when the group was added to the project.
internal/provider/resource_openai_project_group.go (1)

118-124: Consider extracting URL construction to a helper function.

The URL construction logic is duplicated in Create, Read, and Delete. Extracting it to a helper would improve maintainability and reduce the risk of inconsistencies.

Example helper function
func (r *ProjectGroupResource) buildAPIURL(path string) string {
	apiURL := r.client.OpenAIClient.APIURL
	if strings.Contains(apiURL, "/v1") {
		return strings.TrimSuffix(apiURL, "/v1") + "/v1" + path
	}
	return strings.TrimSuffix(apiURL, "/") + "/v1" + path
}

Then use as:

reqURL := r.buildAPIURL("/organization/projects/" + data.ProjectID.ValueString() + "/groups")
internal/provider/data_source_openai_project_group.go (1)

223-229: Add interface assertion for consistency.

The singular data source has an interface assertion (var _ datasource.DataSource = &ProjectGroupDataSource{}), but this list data source is missing one. Adding it ensures compile-time verification.

Proposed fix
+var _ datasource.DataSource = &ProjectGroupsDataSource{}
+
 func NewProjectGroupsDataSource() datasource.DataSource {
 	return &ProjectGroupsDataSource{}
 }

Add full support for managing OpenAI organization groups via Terraform:

New resources:
- openai_group: Create, update, and delete organization groups
- openai_group_user: Add and remove users from groups

New data sources:
- openai_group: Look up a single group by ID or name
- openai_groups: List all groups in the organization
- openai_group_user: Look up a user in a group by ID or email
- openai_group_users: List all users in a group

The implementation follows the OpenAI Admin API spec:
- POST/DELETE /organization/groups for group CRUD
- POST/GET/DELETE /organization/groups/{group_id}/users for user membership

All resources include documentation and examples.

https://claude.ai/code/session_01DtgMTbRXemrjcVjdtfcDR1
- Remove manually written docs that conflict with auto-generated ones
- Apply terraform fmt to example files
- Let tfplugindocs generate docs during CI

https://claude.ai/code/session_01DtgMTbRXemrjcVjdtfcDR1
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🤖 Fix all issues with AI agents
In `@docs/resources/group_user.md`:
- Around line 53-57: Update the docs entry for the added_at field in the
GroupUser response resource: change the description for `added_at` to explicitly
state the unit by using "Unix timestamp (in seconds) when the user was added to
the organization" so it matches other timestamp docs (e.g., `created_at`); edit
the `added_at` line in docs/resources/group_user.md accordingly, leaving other
fields (`id`, `user_name`, `email`, `role`) unchanged.

In `@examples/resources/openai_group_user/resource.tf`:
- Around line 1-16: The example references openai_group.engineering but never
defines it, making the snippet non-functional; add a minimal openai_group
resource definition (or replace with a data source) and update the
openai_group_user blocks to reference that defined group. Specifically, define a
simple openai_group (e.g., name "engineering") or use a data "openai_group"
lookup, and ensure the openai_group_user resources (openai_group_user.engineer,
openai_group_user.support_agent_1, openai_group_user.support_agent_2) reference
the defined group ID instead of an undefined symbol.

In `@internal/provider/data_source_openai_group_user.go`:
- Around line 137-201: The pagination loop that searches for a group user (the
for foundUser == nil loop in internal/provider/data_source_openai_group_user.go)
currently defers httpResp.Body.Close(), which leaks connections across
iterations; remove the defer and instead call httpResp.Body.Close() explicitly
after using the body (i.e., after
json.NewDecoder(httpResp.Body).Decode(&listResp) and after any early return
paths that occur after a response is obtained), and ensure you also call
httpResp.Body.Close() on non-200 responses and error branches before returning
to fully release the connection; update the same pattern in the single-user
search location if present.

In `@internal/provider/data_source_openai_group.go`:
- Around line 121-185: In ReadDataSourceOpenaiGroup (and likewise
ReadDataSourceOpenaiGroups) replace the defer httpResp.Body.Close() inside the
pagination loop with explicit httpResp.Body.Close() calls immediately after
you're done using the response body: call Close() after any error handling that
returns, right after successful json.NewDecoder(httpResp.Body).Decode(&listResp)
(or after decode error handling), and before any loop break or continue so each
iteration releases the connection; ensure every path that exits the iteration
(error, non-200 status, decode success, or early break) invokes
httpResp.Body.Close() so bodies aren’t left open across iterations.

In `@internal/provider/resource_openai_group_user.go`:
- Around line 184-233: In findUserInGroup (and similarly in Read) remove the
defer apiResp.Body.Close() inside the pagination loop and instead call
apiResp.Body.Close() explicitly at each iteration: after handling a non-200
status (before returning), after a JSON decode error (before returning), and
after successfully processing the response data but before the loop continues to
the next page; this ensures each http.Response body is released per iteration
and prevents connection leaks during pagination.
- Around line 378-392: The Delete implementation currently discards errors and
the HTTP response; capture and handle errors from both http.NewRequest and
http.DefaultClient.Do and report failures via the resource's diag collection
(resp.Diagnostics). Specifically, in the Delete method (where apiReq is created
and http.DefaultClient.Do is called) replace the silent returns with adding a
Diagnostic on request creation error, then call resp, err :=
http.DefaultClient.Do(apiReq); if err add a Diagnostic and return, defer
resp.Body.Close(), and if resp.StatusCode is not 2xx read the response body (or
include status text) and add a Diagnostic describing the failure so Terraform
state isn't removed when the remote DELETE failed. Ensure you reference apiReq,
http.DefaultClient.Do, resp and resp.Diagnostics when implementing these checks.

In `@internal/provider/resource_openai_group.go`:
- Around line 335-350: The DELETE request currently ignores both network errors
and non-success HTTP statuses; capture the response and error from
http.DefaultClient.Do(apiReq), check for err and for non-2xx status codes,
read/close the response body, and return a diagnostic error (same pattern as the
Update handler) so Terraform surfaces the failure instead of removing state;
update the delete function around apiReq, http.DefaultClient.Do(apiReq), and use
r.client.OpenAIClient.APIKey / r.client.AdminAPIKey logic as shown to build the
Authorization header when constructing the diagnostic message.
- Around line 200-216: The loop currently defers apiResp.Body.Close(), causing
open connections to pile up across pagination iterations; remove the defer and
close apiResp.Body explicitly after processing each response (i.e., after
decoding into listResp), and also ensure you call apiResp.Body.Close() before
any early returns on non-200 responses or JSON decode errors; look for the HTTP
request/response loop using variables apiReq, apiResp, apiResp.Body, and the
json.NewDecoder(...).Decode(&listResp) call and move/insert explicit
apiResp.Body.Close() calls accordingly.

Add documentation files in tfplugindocs format for:
- openai_group resource
- openai_group_user resource
- openai_project_group resource
- openai_group data source
- openai_groups data source
- openai_group_user data source
- openai_group_users data source
- openai_project_group data source
- openai_project_groups data source

https://claude.ai/code/session_01DtgMTbRXemrjcVjdtfcDR1
…ources

- Handle json.Marshal errors in Create
- Handle io.ReadAll errors with proper diagnostics
- Use dedicated http.Client with 30s timeout instead of http.DefaultClient
- Handle url.Parse errors in pagination loops
- Fix defer in pagination loops - explicitly close response bodies
- Improve Delete method to properly report errors
- Use variables in resource.tf example instead of hardcoded names

https://claude.ai/code/session_01DtgMTbRXemrjcVjdtfcDR1
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@internal/provider/data_source_openai_project_group.go`:
- Around line 163-164: The http.Client is created without a timeout which can
hang indefinitely; update the creation of httpClient (used to Do(httpRequest))
to include a 30s timeout (e.g., set Timeout: 30 * time.Second) and add the
"time" import; ensure the modified httpClient variable is used in the existing
Do(httpRequest) call so behavior matches the resource's 30s timeout.
- Around line 394-395: The HTTP client is created without a timeout (httpClient
:= &http.Client{}) which can cause indefinite hangs; update the creation of
httpClient (the variable used where httpClient.Do(httpRequest) is called) to
include a sensible timeout, e.g. set Timeout: 30 * time.Second on the
http.Client, and add "time" to the imports so the time package is available.

- Update project_group.md example to use variables instead of hardcoded names
- Remove research folder from docs (not part of tfplugindocs output)

https://claude.ai/code/session_01DtgMTbRXemrjcVjdtfcDR1
Add 30 second timeout to http.Client instances in data_source_openai_project_group.go
to prevent indefinite hangs on network issues.

https://claude.ai/code/session_01DtgMTbRXemrjcVjdtfcDR1
- Remove role field from ProjectGroup (not returned by API)
- Add role_id as write-only field for creating group membership
- Add openai_project_roles data source to list project roles
- Add openai_project_group_roles data source for group role assignments
- Add role-related types (RoleResponseFramework, GroupRoleAssignment)
- Update examples and docs to match actual API behavior

The OpenAI API returns only object, project_id, group_id, group_name,
and created_at for ProjectGroup. Roles are managed separately via the
/projects/{project_id}/groups/{group_id}/roles endpoint.

https://claude.ai/code/session_01DtgMTbRXemrjcVjdtfcDR1
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 9

🤖 Fix all issues with AI agents
In `@docs/data-sources/project_roles.md`:
- Around line 1-69: The generated docs file docs/data-sources/project_roles.md
has formatting drift; re-run the docs generator (e.g., run `go generate ./...`
or `tfplugindocs generate`) to regenerate the file (this will update the
header/formatting for the openai_project_roles data source and the nested
`roles` schema), then stage and commit the updated docs so the CI formatting
check passes.

In `@examples/data-sources/openai_project_roles/data-source.tf`:
- Around line 1-27: Run terraform fmt on the
examples/data-sources/openai_project_roles/data-source.tf to fix formatting
issues; ensure the data block named openai_project_roles "all" and the outputs
all_role_ids, role_count, and role_details are reformatted by terraform fmt,
then commit the formatted file.

In `@internal/provider/data_source_openai_project_group_roles.go`:
- Around line 253-258: The code leaves allAssignments nil and roleIDs empty
which yields a null list in state; initialize both slices before populating to
guarantee empty lists instead of nil. Update the block that sets
data.RoleAssignments and data.RoleIDs so allAssignments is created with
make(..., 0) (and roleIDs similarly) prior to the loop that appends entries,
then assign data.RoleAssignments = allAssignments and populate data.RoleIDs
using types.StringValue(v) as before; ensure no slice stays nil so Terraform
state shows an empty list rather than null.
- Around line 161-169: The project group roles endpoint path is inconsistent:
current code in data_source_openai_project_group_roles.go builds suffix :=
fmt.Sprintf("/projects/%s/groups/%s/roles", projectID, groupID) but other
group-related code (data_source_openai_project_group.go and
resource_openai_project_group.go) uses the "/organization/projects/{id}/groups"
prefix; update the suffix to match the canonical path (e.g.
"/organization/projects/%s/groups/%s/roles") so reqURL construction (using
apiURL, strings.Contains(apiURL, "/v1"), and TrimSuffix logic) builds the same
API path as other group endpoints, and adjust any related variable names if
needed to keep consistency with projectID and groupID usage.

In `@internal/provider/data_source_openai_project_group.go`:
- Around line 89-108: The current Read method (ProjectGroupDataSource.Read)
allows both groupID and groupName to be set and can fall back from an ID
mismatch to a name match; change the logic so that if groupID
(data.GroupID.ValueString()) is non-empty you only attempt lookup by ID and
never test groupName, returning a not-found error if no match; otherwise
(groupID empty) perform the name-based search using groupName. Alternatively,
enforce schema-level exclusivity by updating ProjectGroupDataSourceModel's
schema to use ExactlyOneOf/ConflictsWith for group_id and group_name so the
caller cannot provide both—pick one of these two fixes and apply it
consistently.
- Around line 120-128: The URL construction for reqURL using
d.client.OpenAIClient.APIURL and projectID can produce duplicated "/v1" when
apiURL ends with "/v1/"; update the logic in data_source_openai_project_group.go
to robustly trim both trailing "/" and "/v1" (e.g., apply TrimSuffix("/v1") then
TrimSuffix("/")) before appending "/v1" + suffix, mirroring the safe double-trim
approach used in internal/client/client.go; also apply the same fix to the
equivalent implementation in the list data source (the block around lines
336–340) so both code paths build a correct, non-duplicated reqURL.

In `@internal/provider/data_source_openai_project_role.go`:
- Around line 155-156: The variables allRoles and roleIDs are left nil when no
roles exist, causing Terraform to see null instead of an empty list; initialize
them to empty slices (e.g., make([]ProjectRoleResultModel, 0) and make([]string,
0)) where they are declared or before any potential early return in the function
so the data source always returns empty lists instead of nulls; update
references to allRoles and roleIDs in the same function in
data_source_openai_project_role.go to use these initialized slices.

In `@internal/provider/resource_openai_project_group.go`:
- Around line 76-83: The schema's "role_id" attribute is Required and uses
stringplanmodifier.RequiresReplace() but is never populated by Read (it's
write-only), which will cause any imported resource to be flagged for
replacement; update the "role_id" definition (and the other occurrence around
the second mention at the bottom of the file) to avoid this: either make role_id
Optional and Computed so import leaves a known/unknown state instead of null, or
remove the RequiresReplace plan modifier and document the import limitation, or
switch to the framework's write-only attribute support (plugin framework v1.13+)
to mark it write-only; adjust the attribute definition where
stringplanmodifier.RequiresReplace() is set and ensure Read/ReadResource (or the
method that populates state) and the schema reflect the chosen approach.

In `@internal/provider/types_project_org.go`:
- Around line 88-95: ProjectGroupListResponse currently defines FirstID/LastID
and will fail to unmarshal the API's "next" cursor; change the struct
ProjectGroupListResponse to have HasMore bool `json:"has_more"` and Next *string
`json:"next"` (keep Object and Data as-is), remove FirstID/LastID; then update
any pagination logic that reads/writes LastID (e.g., where you check or set
LastID to drive subsequent list calls) to use the new Next pointer (treat nil as
no next page and dereference safely) so calls that previously used LastID now
use Next to pass the cursor.
🧹 Nitpick comments (10)
examples/resources/openai_project_group/resource.tf (1)

1-45: Example file for openai_project_group doesn't demonstrate the resource.

This is the example file for the openai_project_group resource, but every openai_project_group block is commented out. Users browsing docs/examples will only see openai_project resources being created, which doesn't help them understand how to use the group resource.

Consider showing at least one uncommented openai_project_group block with clearly marked placeholder values (e.g., "REPLACE_WITH_GROUP_ID"), so the example is useful as a template. Terraform examples don't need to be directly runnable — they need to be instructive.

internal/provider/data_source_openai_project_group.go (4)

134-200: Consider extracting the shared HTTP request/pagination logic.

Both ProjectGroupDataSource.Read (lines 134–200) and ProjectGroupsDataSource.Read (lines 346–405) duplicate the URL building, request construction, header setting, HTTP execution, status check, JSON decoding, and pagination cursor management. This is a significant amount of copy-paste.

A shared helper like func fetchProjectGroups(apiURL, adminKey, orgID, projectID string) ([]ProjectGroupResponseFramework, error) would eliminate the duplication and make both data sources easier to maintain.


159-159: http.Client is re-created on every loop iteration.

Move the client instantiation before the loop (applies to both Read methods at lines 159 and 370). The client is safe to reuse across requests and benefits from connection pooling.


222-230: Missing interface compliance assertion for ProjectGroupsDataSource.

ProjectGroupDataSource has var _ datasource.DataSource = &ProjectGroupDataSource{} (line 19), but ProjectGroupsDataSource lacks the equivalent check. Add it for consistency and to catch interface drift at compile time.

+var _ datasource.DataSource = &ProjectGroupsDataSource{}
+
 func NewProjectGroupsDataSource() datasource.DataSource {

166-170: API error message lacks response body detail.

When the API returns a non-200 status, the error only reports the status line (e.g., "403 Forbidden"). Reading and including a snippet of the response body would significantly ease debugging — the OpenAI API typically returns a JSON error message. Same applies to the list data source at lines 377-381.

Sketch
 		if httpResp.StatusCode != 200 {
+			body, _ := io.ReadAll(io.LimitReader(httpResp.Body, 1024))
 			httpResp.Body.Close()
-			resp.Diagnostics.AddError("API Error", fmt.Sprintf("Status: %s", httpResp.Status))
+			resp.Diagnostics.AddError("API Error", fmt.Sprintf("Status: %s, Body: %s", httpResp.Status, string(body)))
 			return
 		}
internal/provider/data_source_openai_project_group_roles.go (2)

189-189: Use http.NewRequestWithContext to propagate the Terraform context.

The ctx parameter is available but not passed to the HTTP request. If Terraform cancels the operation (e.g., timeout or user interrupt), the in-flight HTTP call won't be cancelled.

♻️ Proposed fix
-		httpRequest, err := http.NewRequest("GET", parsedURL.String(), nil)
+		httpRequest, err := http.NewRequestWithContext(ctx, "GET", parsedURL.String(), nil)

164-169: URL construction with /v1 detection is fragile and duplicated.

The strings.Contains(apiURL, "/v1") / TrimSuffix pattern is repeated across every data source and resource file. Consider extracting a shared helper (e.g., buildAdminURL(apiURL, path string) string) to reduce duplication and the risk of inconsistent fixes.

internal/provider/data_source_openai_project_role.go (2)

173-173: Use http.NewRequestWithContext to respect cancellation.

Same issue as in data_source_openai_project_group_roles.go — the ctx from the Read method signature is not propagated to the HTTP request.

♻️ Proposed fix
-		httpRequest, err := http.NewRequest("GET", parsedURL.String(), nil)
+		httpRequest, err := http.NewRequestWithContext(ctx, "GET", parsedURL.String(), nil)

127-243: Significant code duplication with other data sources.

The Read method here is nearly identical to data_source_openai_project_group_roles.go (admin key check, URL construction, HTTP client creation, pagination loop, header setup, error handling). This pattern is repeated across at least two data sources in this PR.

Consider extracting a shared pagination helper, e.g.:

func (c *OpenAIClient) paginatedGet(ctx context.Context, urlPath string, out interface{}) error { ... }

This would centralize timeout, auth headers, org header, /v1 normalization, and cursor-based iteration.

internal/provider/resource_openai_project_group.go (1)

132-132: Use http.NewRequestWithContext across Create, Read, and Delete.

All three lifecycle methods create HTTP requests via http.NewRequest while the ctx parameter is available. Pass the context so Terraform timeouts and cancellations propagate to in-flight API calls.

♻️ Proposed fix (apply to all three call sites)
 // Create (line 132)
-	apiReq, err := http.NewRequest("POST", reqURL, bytes.NewReader(reqBody))
+	apiReq, err := http.NewRequestWithContext(ctx, "POST", reqURL, bytes.NewReader(reqBody))

 // Read (line 230)
-		apiReq, err := http.NewRequest("GET", parsedURL.String(), nil)
+		apiReq, err := http.NewRequestWithContext(ctx, "GET", parsedURL.String(), nil)

 // Delete (line 331)
-	apiReq, err := http.NewRequest("DELETE", reqURL, nil)
+	apiReq, err := http.NewRequestWithContext(ctx, "DELETE", reqURL, nil)

Also applies to: 230-230, 331-331

- Fix ProjectGroupListResponse to use Next pointer instead of FirstID/LastID
- Fix URL construction with safer double-trim approach
- Fix group lookup logic to only search by ID when groupID is provided
- Make role_id Optional+Computed to handle import properly
- Initialize slices to avoid nil (prevent null in Terraform state)
- Use consistent /organization/projects path prefix for all endpoints
- Update pagination to use Next pointer for cursor

https://claude.ai/code/session_01DtgMTbRXemrjcVjdtfcDR1
Simplify the role data source examples to use simpler for expressions
that won't cause terraform fmt formatting drift.

https://claude.ai/code/session_01DtgMTbRXemrjcVjdtfcDR1
- Fix gofmt formatting issue in data_source_openai_project_group_roles.go
- Add missing variables.tf for openai_project_roles example
- Add missing variables.tf for openai_project_group_roles example

https://claude.ai/code/session_01DtgMTbRXemrjcVjdtfcDR1
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@internal/provider/resource_openai_project_group.go`:
- Around line 76-83: The role_id attribute is defined as Optional+Computed with
stringplanmodifier.UseStateForUnknown() but lacks a RequiresReplace() modifier,
so changing it triggers the resource Update path which currently unconditionally
errors (see the Update method that returns an error); either add
planmodifier.RequiresReplace() to the role_id schema attribute (the "role_id"
schema in resource_openai_project_group.go) to force destroy/recreate on
changes, or implement patch/update logic inside the resource's Update method to
handle role_id changes (update the API call and state accordingly) so Update no
longer returns the error.
🧹 Nitpick comments (6)
internal/provider/data_source_openai_project_group_roles.go (2)

188-209: Use http.NewRequestWithContext to propagate the Terraform context.

Using http.NewRequest ignores the ctx passed by Terraform. If a user cancels the operation (e.g., Ctrl+C), the in-flight HTTP request won't be cancelled. Additionally, the error diagnostic on non-200 responses is sparse — reading the response body would help operators debug API failures.

Suggested fix
-		httpRequest, err := http.NewRequest("GET", parsedURL.String(), nil)
+		httpRequest, err := http.NewRequestWithContext(ctx, "GET", parsedURL.String(), nil)

And for the error path:

 		if httpResp.StatusCode != 200 {
-			httpResp.Body.Close()
-			resp.Diagnostics.AddError("API Error", fmt.Sprintf("Status: %s", httpResp.Status))
+			bodyBytes, _ := io.ReadAll(httpResp.Body)
+			httpResp.Body.Close()
+			resp.Diagnostics.AddError("API Error", fmt.Sprintf("Status: %s - %s", httpResp.Status, string(bodyBytes)))
 			return
 		}

Note: io import would be needed.


142-260: Significant code duplication with data_source_openai_project_role.go.

The Read methods in this file and data_source_openai_project_role.go share nearly identical pagination, URL construction, HTTP request setup, and error handling logic. Consider extracting a shared helper (e.g., paginatedAdminGet[T any](ctx, client, path, httpClient) ([]T, error)) to reduce duplication and centralize bug fixes.

internal/provider/data_source_openai_project_role.go (1)

172-193: Use http.NewRequestWithContext to respect cancellation.

Same as in data_source_openai_project_group_roles.gohttp.NewRequest doesn't propagate the Terraform ctx, so cancellation signals are ignored. Also, the non-200 error path discards the response body which often contains useful error details from the API.

Suggested fix
-		httpRequest, err := http.NewRequest("GET", parsedURL.String(), nil)
+		httpRequest, err := http.NewRequestWithContext(ctx, "GET", parsedURL.String(), nil)
internal/provider/resource_openai_project_group.go (3)

132-132: Use http.NewRequestWithContext throughout for cancellation support.

All three HTTP request creation points (Create Line 132, Read Line 230, Delete Line 331) use http.NewRequest without propagating ctx. This means Terraform cancellation signals (Ctrl+C) won't abort in-flight requests.

Suggested fix (apply to all three locations)
-	apiReq, err := http.NewRequest("POST", reqURL, bytes.NewReader(reqBody))
+	apiReq, err := http.NewRequestWithContext(ctx, "POST", reqURL, bytes.NewReader(reqBody))
-		apiReq, err := http.NewRequest("GET", parsedURL.String(), nil)
+		apiReq, err := http.NewRequestWithContext(ctx, "GET", parsedURL.String(), nil)
-	apiReq, err := http.NewRequest("DELETE", reqURL, nil)
+	apiReq, err := http.NewRequestWithContext(ctx, "DELETE", reqURL, nil)

Also applies to: 230-230, 331-331


124-130: URL construction pattern differs from data sources and is fragile.

The resource uses strings.Contains(apiURL, "/v1") which could match /v1 appearing anywhere in the URL (e.g., a hostname). The data sources use a safer TrimSuffix approach. Consider aligning for consistency:

-	var reqURL string
-	if strings.Contains(apiURL, "/v1") {
-		reqURL = strings.TrimSuffix(apiURL, "/v1") + "/v1/organization/projects/" + data.ProjectID.ValueString() + "/groups"
-	} else {
-		reqURL = strings.TrimSuffix(apiURL, "/") + "/v1/organization/projects/" + data.ProjectID.ValueString() + "/groups"
-	}
+	baseURL := strings.TrimSuffix(apiURL, "/v1")
+	baseURL = strings.TrimSuffix(baseURL, "/")
+	reqURL := baseURL + "/v1/organization/projects/" + data.ProjectID.ValueString() + "/groups"

Same applies to the Read (Lines 201-206) and Delete (Lines 323-329) methods.


184-297: Read paginates through all groups to find one — consider documenting the performance implication.

Since no single-group GET endpoint exists (Line 199 comment), Read iterates all groups page by page. For projects with many groups, this will be slow during every refresh. The comment at Line 199 is good, but consider adding a tflog.Warn or tflog.Debug when pagination exceeds the first page so operators can identify performance bottlenecks.

claude and others added 3 commits February 11, 2026 11:28
The role_id attribute was changed from Required to Optional+Computed
to support imports where the API does not return the role_id value.

https://claude.ai/code/session_01DtgMTbRXemrjcVjdtfcDR1
- Replace defer in loops with explicit Body.Close() after consumption
- Add 30s HTTP client timeout (was using http.DefaultClient without timeout)
- Handle url.Parse errors instead of ignoring them
- Handle json.Marshal and io.ReadAll errors properly
- Add proper error handling in Delete methods (was silently ignoring errors)
- Initialize slices with make() to avoid nil/null in Terraform state
- Add missing group resource definition in group_user example
- Regenerate docs
@Fodoj Fodoj changed the title feat: add openai_project_group resource and data sources feat: add group management, role management, and project membership resources Feb 17, 2026
@Fodoj Fodoj merged commit 603e212 into main Feb 17, 2026
7 checks 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.

2 participants