# APIPark team-scope authorization bypass lets any team manager read, edit, and delete another team's services and apps

**Affected:** v1.9.7-beta and earlier 1.9.x, pinned commit `b6b58db20bfb8903a5bd623a0272dda16388dbb5`
**Class:** CWE-639 Authorization Bypass Through User-Controlled Key, CWE-862 Missing Authorization
APIPark is a multi-tenant gateway where users belong to teams and hold per-team roles. The management REST API is guarded by a permit middleware that requires either a system-workspace capability or a team capability per route. The team capability is resolved by reading the team identifier directly from the request query string `?team=`, so the middleware evaluates the caller's role inside a team the caller names, not inside the team that owns the object being touched. The service and application management routes (`/api/v1/service/info`, `/api/v1/app/info`, `/api/v1/app`) are keyed only by an object id and carry no team parameter, and neither the controller nor the module compares the object's owning team against the caller. A user who manages their own team can append their own team id to any of these requests, pass the capability check, and then read, edit, or delete a service or application that belongs to a different team. The trust boundary crossed is the boundary between two different tenants. Under the default configuration these routes are reachable by any authenticated team member with the relevant team role. The reviewed version is v1.9.7-beta at commit `b6b58db20bfb8903a5bd623a0272dda16388dbb5`.
APIPark enforces route authorization in a single gin middleware. The middleware looks up the capabilities configured for the matched route, then for each identity group it asks a domain handler for the caller's capabilities and allows the request if any required capability is present.
### The permit middleware checks capability membership only
func (p *PermitMiddleware) Check(method string, path string) (bool, []gin.HandlerFunc) {
accessRules, has := permit.GetPathRule(method, path)
ginCtx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": http.StatusForbidden, "msg": "not login", "success": "fail"})
domainHandler, has := permit.SelectDomain(group)
accessMap := utils.SliceToMapO(myAccess, func(s string) (string, struct{}) {
log.DebugF("no permission:%s", ginCtx.FullPath())
ginCtx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"code": http.StatusForbidden, "msg": "no permission", "success": "fail"})
The middleware allows the request when the caller holds the required capability in some identity group. It never receives the object id and never checks that the object belongs to the team the capability was granted in.
### The team domain handler trusts a caller-supplied team id
func (m *imlTeamPermitModule) domain(ctx *gin.Context) ([]string, []string, bool) {
return []string{role.GroupTeam}, accesses, true
`accesses` returns the capabilities the caller holds in the team named by `?team=`.
func (m *imlTeamPermitModule) accesses(ctx context.Context, teamId string) ([]string, error) {
if utils.GuestAllow() && utils.IsGuest(ctx) {
return access.GuestAccess(role.GroupTeam), nil
roleMembers, err := m.roleMemberService.List(ctx, role.TeamTarget(teamId), uid)
roleIds := utils.SliceToSlice(roleMembers, func(rm *role.Member) string {
roles, err := m.roleService.List(ctx, roleIds...)
The caller controls `teamId`. If the caller manages team A, passing `?team=A` returns the manager capabilities for team A. The capability is real, it is just evaluated against the wrong team.
### The object routes carry no team parameter
pm3.CreateApiWidthDoc(http.MethodGet, "/api/v1/service/info", []string{"context", "query:service"}, []string{"service"}, p.serviceController.Get, access.SystemWorkspaceServiceViewAll, access.TeamTeamServiceView)
pm3.CreateApiWidthDoc(http.MethodPut, "/api/v1/service/info", []string{"context", "query:service", "body"}, []string{"service"}, p.serviceController.Edit, access.SystemWorkspaceServiceManagerAll, access.TeamTeamServiceManager)
pm3.CreateApiWidthDoc(http.MethodDelete, "/api/v1/service/info", []string{"context", "query:service"}, nil, p.serviceController.Delete, access.SystemWorkspaceServiceManagerAll, access.TeamTeamServiceManager)
The service routes bind `query:service` and require the team capability `TeamTeamServiceView` or `TeamTeamServiceManager`, but they never bind `query:team`. The app routes follow the same shape.
pm3.CreateApiWidthDoc(http.MethodGet, "/api/v1/app/info", []string{"context", "query:app"}, []string{"app"}, p.appController.GetApp, access.SystemWorkspaceApplicationViewAll, access.TeamTeamConsumerView)
pm3.CreateApiWidthDoc(http.MethodDelete, "/api/v1/app", []string{"context", "query:app"}, nil, p.appController.DeleteApp, access.SystemWorkspaceApplicationManagerAll, access.TeamTeamConsumerManager)
pm3.CreateApiWidthDoc(http.MethodPut, "/api/v1/app/info", []string{"context", "query:app", "body"}, []string{"app"}, p.appController.UpdateApp, access.SystemWorkspaceApplicationManagerAll, access.TeamTeamConsumerManager)
A legitimate team-scoped route needs the team to bind the capability, for example the member routes carry `query:team`.
pm3.CreateApiWidthDoc(http.MethodPost, "/api/v1/team/member", []string{"context", "query:team", "body"}, nil, p.myTeamController.AddMember, access.SystemWorkspaceTeamManager, access.TeamTeamMemberManager)
pm3.CreateApiWidthDoc(http.MethodDelete, "/api/v1/team/member", []string{"context", "query:team", "query:user"}, nil, p.myTeamController.RemoveMember, access.SystemWorkspaceTeamManager, access.TeamTeamMemberManager)
Here the capability binds to the `?team=` the operation acts on, which is correct. The object routes do not, so the attacker is free to supply their own team.
### The sink reads and writes the object by id with no team comparison
func (i *imlServiceModule) Get(ctx context.Context, id string) (*service_dto.Service, error) {
serviceInfo, err := i.serviceService.Get(ctx, id)
func (i *imlServiceModule) Edit(ctx context.Context, id string, input *service_dto.EditService) (*service_dto.Service, error) {
info, err := i.serviceService.Get(ctx, id)
func (i *imlServiceModule) Delete(ctx context.Context, id string) error {
err := i.transaction.Transaction(ctx, func(ctx context.Context) error {
count, err := i.apiService.CountByService(ctx, id)
return fmt.Errorf("service has apis, can not delete")
The service record carries a `Team` field, visible where listing maps it (`module/service/iml.go` `toServiceItem` sets `Team: auto.UUID(model.Team)`), but `Get`, `Edit`, and `Delete` never read that field to compare it against the caller. The application path is the same, and the owner comparison is present only as commented-out code.
func (i *imlAppModule) UpdateApp(ctx context.Context, appId string, input *service_dto.UpdateApp) (*service_dto.App, error) {
info, err := i.serviceService.Get(ctx, appId)
// return nil, fmt.Errorf("user is not app master, can not update")
err = i.serviceService.Save(ctx, appId, &service.Edit{
func (i *imlAppModule) DeleteApp(ctx context.Context, appId string) error {
info, err := i.serviceService.Get(ctx, appId)
if !errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("not app, can not delete")
return i.serviceService.Delete(ctx, appId)
The chain is complete. The capability check validates membership in an attacker-chosen team, and the sink operates on an object id from a different team with no ownership read.
participant A as Attacker, manager of team A
A->>MW: DELETE service info with foreign service id plus own team id
MW->>TH: resolve team capabilities for team id from query
TH->>TH: read team id from query string, look up caller roles in team A
TH-->>MW: caller holds service manager capability in team A
MW->>MW: required team capability is present, allow
MW->>SM: call Delete with the foreign service id
SM->>DB: delete service row by id, no team comparison
Prerequisites. One APIPark instance with two tenants. The attacker is an authenticated user who manages team A (holds `TeamTeamServiceManager` and `TeamTeamConsumerManager` in team A, which any team owner has by default). A victim team B owns a service with id `svc-B` and an application with id `app-B`. The attacker knows or discovers the target object ids. Object ids are UUIDs, and the service and application listing, catalogue, and subscription endpoints surface ids broadly across the workspace, so a team member can enumerate them. The requests below are illustrative and were not executed against a live deployment.
- Request: `GET /api/v1/service/info?service=svc-B&team=A` with the attacker's session cookie.
- Source-derived behavior: the permit middleware reads `?team=A`, confirms the attacker holds `TeamTeamServiceView` in team A, and allows the call. `imlServiceController.Get` calls `imlServiceModule.Get(ctx, "svc-B")`, which fetches `svc-B` by id.
- State change: none, this is a read. The response body contains team B's service configuration, including for an AI service the upstream provider id and model mapping.
2. Attacker reads team B's service documentation.
- Request: `GET /api/v1/service/doc?service=svc-B&team=A`.
- Source-derived behavior: route requires `TeamServiceServiceIntroView`, satisfied in team A, handler returns the doc for `svc-B`.
- State change: none, team B internal documentation is disclosed.
- Request: `PUT /api/v1/service/info?service=svc-B&team=A` with a JSON body changing the name, description, approval type, or AI provider and model.
- Source-derived behavior: `imlServiceModule.Edit(ctx, "svc-B", input)` loads `svc-B` and saves the attacker's fields.
- State change: team B's service is reconfigured by a user outside team B.
- Request: `DELETE /api/v1/service/info?service=svc-B&team=A`.
- Source-derived behavior: `imlServiceModule.Delete(ctx, "svc-B")` runs. If the service has no published APIs the row and its gateway bindings are removed.
- State change: team B's service is destroyed.
5. Attacker renames or deletes a foreign application.
- Request: `PUT /api/v1/app/info?app=app-B&team=A` to rename, or `DELETE /api/v1/app?app=app-B&team=A` to delete.
- Source-derived behavior: `imlAppModule.UpdateApp` or `DeleteApp` loads `app-B` and modifies or removes it with no team comparison.
- State change: team B's application is renamed or destroyed, which revokes its subscriptions and credentials.
The chain was confirmed by reading the pinned source at commit `b6b58db20bfb8903a5bd623a0272dda16388dbb5` (v1.9.7-beta) through GitHub source pages. The exact locations are:
- Capability-only gate: `middleware/permit/permit.go:41-85`.
- Team id taken from the request query: `module/permit/team/iml.go:70-78` and `:35-60`.
- Object routes with a team capability but no team parameter: `plugins/core/service.go:16-18`, `:30`, `:32`, `:40`, and `plugins/core/authorization.go:12-16`.
- A correctly bound team route for contrast: `plugins/core/my-team.go:17-18`.
- Sinks that read and write by id with no team read: `module/service/iml.go:551`, `:787`, `:908`, `:1248`, `:1415`.
Runtime validation was not performed. No APIPark instance was built or run, no route was called, and no deployment was contacted. The HTTP status codes and response bodies described in the attack steps are inferred from the source path, not observed.
A user who manages any one team can read, modify, and delete services and applications owned by any other team on the same instance. For AI services the disclosed configuration includes the upstream provider binding and model mapping. Edit lets an outsider change another team's service name, description, approval policy, and provider or model. Delete destroys another team's service or application and tears down its gateway bindings and subscriptions, which is an integrity and availability impact on a different tenant.
This is distinct from CVE-2026-108862. That issue is the authorization-record mismatch where a foreign `authorization` UUID is read while the supplied `app` is discarded, within a single already-authorized application. This finding is the broader team-scope bypass that makes the SERVICE and APPLICATION objects themselves reachable across teams, including destructive delete, which the earlier issue does not cover.
What is NOT established. No runtime status code or response body was observed, so each step's exact result is inferred from source. There is no remote code execution and no host file access. Discoverability of foreign object ids by a strictly team-scoped attacker depends on which listing endpoints the attacker can reach, although ids are surfaced broadly across the workspace. A single-team deployment with no second tenant does not cross a boundary.
**Where exactly:** `module/permit/team/iml.go:70-78` and the object routes in `plugins/core/service.go` and `plugins/core/authorization.go`, plus the sinks in `module/service/iml.go`.
The team capability must be evaluated against the team that OWNS the object, not against a team id supplied by the caller. Resolve the owning team server-side from the object id and check the caller's capability in that team.
- func (m *imlTeamPermitModule) domain(ctx *gin.Context) ([]string, []string, bool) {
- accesses, err := m.accesses(ctx, teamId)
- return []string{role.GroupTeam}, accesses, true
+ func (m *imlTeamPermitModule) domain(ctx *gin.Context) ([]string, []string, bool) {
+ // Resolve the team that owns the addressed object, never trust a caller-supplied team for object routes.
+ teamId, ok := resolveOwningTeam(ctx) // derive from ?service= / ?app= via the service record's Team field
+ accesses, err := m.accesses(ctx, teamId)
+ return []string{role.GroupTeam}, accesses, true
Short term. In each object handler, load the object, read its `Team`, and verify the caller holds the required capability in that exact team before acting. Uncomment and restore the owner or team check in `UpdateApp` and add the same to `GetApp`, `DeleteApp`, service `Get`, `Edit`, and `Delete`.
Medium term. Replace the caller-supplied `?team=` for object routes with server-side resolution of the owning team from the object id, so the capability decision and the object can never disagree.
Defense in depth. Add an integration test that, as a manager of team A, attempts every `service/info`, `app/info`, and `app` operation against a team B object and asserts a 403. Audit every route that carries a team capability without a `query:team` that binds to the acted-on object.
Recommended fix: bind the team capability to the object's owning team resolved server-side, and add an object-level ownership check in every service and app handler so a capability granted in one team cannot act on another team's objects.
- No public advisory for the team-scope bypass at time of writing. Related but distinct: CVE-2026-108862, the authorization-record UUID mismatch in the same product.
- Permit middleware:
- Team domain handler:
- Service and app routes:
- App authorization routes:
- Service module sinks:
- Correctly bound team route for contrast:
- Fixed release: none at time of writing.
Reported by hieuPenguinnn, [github.com/hieuPenguinnn](
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
