Skip to content

[Write restricted SOs] Allow creation, update, delete and transfer ownership#224411

Merged
SiddharthMantri merged 273 commits intoelastic:security/read-only-dashboardsfrom
SiddharthMantri:security/read-only-dashboards-crud-api
Oct 21, 2025
Merged

[Write restricted SOs] Allow creation, update, delete and transfer ownership#224411
SiddharthMantri merged 273 commits intoelastic:security/read-only-dashboardsfrom
SiddharthMantri:security/read-only-dashboards-crud-api

Conversation

@SiddharthMantri
Copy link
Copy Markdown
Contributor

@SiddharthMantri SiddharthMantri commented Jun 18, 2025

Closes #221753


Important

To support requirements, this feature is restricted to SO types that are multiple and multiple-isolated namespace types only.


Summary

Summarize your PR. If it involves visual changes include a screenshot or gif.

  • Change Ownership Endpoints: Adds server-side logic, client methods, and repository support to transfer object ownership.

  • Extended Create Options : Allow specifying accessMode: 'read_only' when creating an object.

  • Security & Authorization: Integrates with the security extension to enforce owner-based restrictions.

  • Mappings & Migrations: Elasticsearch mappings and migrations updated to persist accessControl.

  • Tests: New FTR integration tests and a dedicated test plugin for read-only object scenarios.

Ownership-Based Authorization Logic

All ownership checks are now centralized in
SavedObjectsAccessControlService.enforceAccessControl(operation, args…).
The general flow is:

  1. Load existing object to read its accessControl metadata.
  2. Determine required privilege based on the operation:
    • create
    • read
    • modify (covers both update & delete)
    • changeOwnership
  3. If object.accessControl.accessMode === 'read_only', also enforce user === object.accessControl.owner.
  4. Always verify the caller holds the corresponding privilege.
Operation Privilege Key Read-Only Constraint Notes
Create create Must supply accessControl.owner accessControl.accessMode passed in create options
Read read N/A Falls back to standard read privileges
Modify (update/delete) modify Caller must be the current owner or admin Single hook for both update & delete
Change Ownership changeOwnership Caller must be the current owner or admin Performs a preflight multi-get, then bulk-updates owner based on result

Assumptions

  1. Update operations will not support changing accessControl metadata.
  2. Updates (and in turn upserts) will also not allow objects to be created as write restricted types. We throw an error and ask the consumer to use create instead.
  3. Bulk Delete operations will behave as expected with the new changes. Superusers/owners can bulk delete objects. Non-owners cannot bulk delete objects that are in write restricted mode. (Assuming that they otherwise have the permission to do so)
  4. Bulk delete with force option works the same way.

Expected behavior of SOR operations

All operations in the SOR on accessControl types follow the same rules. After the existing RBAC rules for these operations, for a user to be granted permission to perform an action on an SO Type supporting access control, they must be superuser or owner of the object.

  1. Create
    Objects supporting access control will be created with the accessControl metadata as follows:
    owner: User profile ID of creating user
    accessMode: read_only | default based on the incoming option

If objects supporting access control are created with no access control metadata, then they are created just as they are today - no change to how that object type will function.

  1. Read
    No changes to read. Must have RBAC granted access for SOs.

  2. Update
    Update operations will not support changing accessControl metadata. Updates (and in turn upserts) will also not allow objects to be created as write restricted types. We throw an error and ask the consumer to use create instead. Updates are restricted by the access control rules stated above

  3. Delete
    Delete operations will be allowed/disallowed based on the rules above.

  4. Bulk update
    Objects supporting access control but not owned by the current user (unless superuser) will not be updated in this operation.

  5. Bulk delete
    Bulk delete (with and without the force option) work the same way. If an object in the payload is of a type supporting access control, then the rules will be the same. We will allow delete only if the user is a superuser or owner of said object - all other instances will be rejected.

  6. Delete by namespace
    Delete by namespace is a workaround for bulk deleting all objects in a space. This function isn't exposed on the SO client but rather used on the repository which is used by the SpacesClient to be able to delete all objects in a space. For users with a role granting this permission, we will continue to allow them to do so even if there are objects owned by others in that space. This is product decision based on the Editor role being granted this privilege and if removed, would be considered a breaking change that's out of the scope of this project.

Misc:
Saved objects like dashboards that are shareable across spaces have the same rules assigned to them. A user cannot update/delete a dashboard in a space where they might have RBAC access but don't own said dashboard.

How to test

Since there's no associated UI changes in this PR, you can use the test read only objects plugin to test behavior of the different flows.

Start the plugin in the integration test as:

node scripts/functional_tests_server --config x-pack/platform/test/spaces_api_integration/access_control_objects/config.ts

Once done, you can open Kibana on localhost:5620 and set up a simple user with this role: kibana_savedobjects_editor. For your tests you can use three users:

  1. elastic:changeme superuser
  2. test_user:changeme non-superuser
  3. The user just created above with role kibana_savedobjects_editor

With each of these users, you can try the following calls in Dev Tools

  1. Create a read only object / non-read only
POST kbn:/access_control_objects/create
{"isReadOnly": true} // switch to false if creating regular SO type

Creates and responds with an object owned by the creating user. Using the ID in the response, you can now perform the different operations and verify how access control works

You can run

GET kbn:/access_control_objects/<ID>

at any time to see the current shape of the object including the access control metadata

  1. Updates - Run this as admin/owner/non-owner to test and verify the different responses
PUT kbn:/access_control_objects/update
{
  "objectId": "<ID FROM CREATE>",
  "type": "access_control_type"
}
  1. Delete - Run this as admin/owner/non-owner to test and verify the different responses
DELETE kbn:/access_control_objects/<ID FROM CREATE>
  1. Bulk delete - Run this as admin/owner/non-owner to test and verify the different responses
POST kbn:/read_only_objects/bulk_delete
{
  "objects": [
    {
      "id": "<ID1>",
      "type": "access_control_type"
    },
    {
      "id": "<ID2>",
      "type": "access_control_type"
    },
    // ...
  ]
}
  1. Bulk delete with force - same as above with force option
POST kbn:/access_control_objects/bulk_delete
{
  "force": true,
  "objects": [
    {
      "id": "<ID1>",
      "type": "access_control_type"
    },
    {
      "id": "<ID2>",
      "type": "access_control_type"
    },
    // ...
  ]
}

Note for reviewers:

  • There are a lot of changes owned by the core team. Although most of the authz and ownership changes are in the security extension owned by Kibana platform security, surfacing these APIs to the SO client touches a lot of interfaces owned by core.

Follow up tasks

We have a few different follow up tasks documented on #230991

Checklist

Check the PR satisfies following conditions.

Reviewers should verify this PR satisfies this list as well.

  • Documentation was added for features that require explanation or tutorials
  • Unit or functional tests were updated or added to match the most common scenarios
  • Flaky Test Runner was used on any tests changed
  • The PR description includes the appropriate Release Notes section, and the correct release_note:* label is applied per the guidelines

Identify risks

Risk matrix attached on parent PR: #224552 (comment)

@SiddharthMantri SiddharthMantri requested a review from Copilot June 19, 2025 12:11

This comment was marked as outdated.

@SiddharthMantri SiddharthMantri requested a review from Copilot June 19, 2025 13:31

This comment was marked as outdated.

@SiddharthMantri SiddharthMantri requested a review from Copilot June 20, 2025 13:32

This comment was marked as outdated.

@SiddharthMantri
Copy link
Copy Markdown
Contributor Author

@elasticmachine merge upstream

@SiddharthMantri SiddharthMantri requested a review from Copilot June 20, 2025 21:04

This comment was marked as outdated.

Copy link
Copy Markdown
Contributor

@jeramysoucy jeramysoucy left a comment

Choose a reason for hiding this comment

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

Thanks for your patience with this, Sid. I commented with a suggestion to add a test case, but this can be added in one of the follow-up PRs.

);
expect(client.create).not.toHaveBeenCalled();
});
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Add 'allows creating an object supporting access control with no access control metadata when there is no active user profile and no access mode is provided'

Comment on lines +52 to +69
const nameAttribute = registry.getNameAttribute(type);

const savedObjectResponse = await client.get<SavedObjectsRawDocSource>(
{
index: commonHelper.getIndexForType(type),
id: serializer.generateRawId(namespace, type, id),
_source_includes: [
...SavedObjectsUtils.getIncludedNameFields(type, nameAttribute),
'accessControl',
],
},
{ ignore: [404], meta: true }
);

const saveObject = { attributes: savedObjectResponse.body._source?.[type] };
const name = securityExtension.includeSavedObjectNames()
? SavedObjectsUtils.getName(nameAttribute, saveObject)
: undefined;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

++ I think this is simpler than what was there before, and changes the existing code less.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It might make sense to just make sure the change access control function is being called correctly. If you agree, can we take care of this in the follow-up PR to restructure the tests?

Copy link
Copy Markdown
Contributor

@TinaHeiligers TinaHeiligers left a comment

Choose a reason for hiding this comment

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

Approving to unblock further work, as long as the follow up tasks are handled soon.

Anything saved object related's far more complex than one might think and it takes stamina to get a PR over the finish line. Great work @SiddharthMantri !

@jeramysoucy
Copy link
Copy Markdown
Contributor

Approving to unblock further work, as long as the follow up tasks are handled soon.

Thanks, Tina. We've merged a config feature flag already to gate this. We have the immediate follow-up PRs that are all staged and nearly ready:

Copy link
Copy Markdown
Contributor

@jloleysens jloleysens 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 looking good @SiddharthMantri !

I'm leaving a "request changes" because I'd like to get your thoughts on the set of questions (mostly nits!) about the Core code before proceeding. I intend to also test this behaviour and read through the security code so part 2 is incoming!

Cheers!


it('returns error if no read-only objects are specified', async () => {
const params = setup({
objects: [{ type: NON_READ_ONLY_TYPE, id: 'id-1' }],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: perhaps worth specifying a set objects in some test (not just one) and checking the output


// Regular expression to validate user profile IDs as generated by Elasticsearch
// User profile IDs are expected to be in the format "u_<principal>_<version>"
const USER_PROFILE_REGEX = /^u_.+_.+$/;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This regexp will match:

/^u_.+_.+$/.test('u_asd_asd_asd');
// => true

Is asd_asd a valid principle name?

@@ -111,7 +111,7 @@ describe('#delete', () => {

it(`should use ES get action then delete action when using a multi-namespace type`, async () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Would you mind changing this test name given the .not?


// UPSERT CASE START
if (shouldPerformUpsert) {
// Note: Upsert does not support adding accessControl properties. To do so, the `create` API should be used.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Would you mind explaining bit more on the internals of why in this comment?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@SiddharthMantri Do you think we should just handle this case? If there is an active user profile, then we can set as the owner and use the default mode, otherwise just create the object "legacy" style with no access control?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

To be addressed in #239686

if (shouldPerformUpsert) {
// Note: Upsert does not support adding accessControl properties. To do so, the `create` API should be used.

if (registry.supportsAccessControl(type)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If this type !supportsAccessControl, should we be throwing a different error? I'd also just like to understand the rationale for this blocker a bit better, thanks!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Only types supporting accessControl reject upserts. All other types, given that they pass other checks, should allow upsert.

});

const createSecurityExtension = (): jest.Mocked<ISavedObjectsSecurityExtension> =>
lazyObject({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why did you remove lazyObject? Was it causing issues?

@elasticmachine
Copy link
Copy Markdown
Contributor

elasticmachine commented Oct 20, 2025

💔 Build Failed

Failed CI Steps

Test Failures

  • [job] [logs] Jest Integration Tests #2 / Migration actions - serverless environment createIndex resolves left cluster_shard_limit_exceeded when the action would exceed the maximum normal open shards
  • [job] [logs] Jest Integration Tests #2 / Migration actions - serverless environment createIndex resolves left cluster_shard_limit_exceeded when the action would exceed the maximum normal open shards

Metrics [docs]

Public APIs missing comments

Total count of every public API that lacks a comment. Target amount is 0. Run node scripts/build_api_docs --plugin [yourplugin] --stats comments for more detailed information.

id before after diff
@kbn/core-saved-objects-api-server 5 13 +8
@kbn/core-saved-objects-common 40 41 +1
@kbn/core-saved-objects-server 141 150 +9
total +18

Public APIs missing exports

Total count of every type that is part of your API that should be exported but is not. This will cause broken links in the API documentation system. Target amount is 0. Run node scripts/build_api_docs --plugin [yourplugin] --stats exports for more detailed information.

id before after diff
@kbn/core 911 920 +9
Unknown metric groups

API count

id before after diff
@kbn/core-saved-objects-api-server 365 389 +24
@kbn/core-saved-objects-common 73 74 +1
@kbn/core-saved-objects-server 587 608 +21
total +46

History

Copy link
Copy Markdown
Contributor

@jloleysens jloleysens left a comment

Choose a reason for hiding this comment

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

I ran the code locally, thanks for addressing my feedback. Happy with the incremental approach to address issues regarding upsert. Nice work @SiddharthMantri, approving to unblock progress. We may just need @rudolf to also take re-review!

Comment on lines +54 to +56
const loginAsObjectOwner = (username: string, password: string) => login(username, password);

const loginAsNotObjectOwner = (username: string, password: string) => login(username, password);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: these loginAs* functions seem to be identical. Is that intentional?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, they are redundant as such but help with readability. The idea is that when used in the test cases, it's obvious which user is performing the action.

@SiddharthMantri SiddharthMantri merged commit 27c9862 into elastic:security/read-only-dashboards Oct 21, 2025
10 of 12 checks passed
SiddharthMantri added a commit that referenced this pull request Oct 29, 2025
Closes #221755

## Summary

Adds audit events on successful completion of changing object owner or
access mode.


Detailed list of audit events on issue attached above. 

### Audit events added in this PR: 

| Audit Action | When Audited | Outcome | Bypass Conditions | 
|--------------|--------------|---------|-------------------|
| **UPDATE_OBJECTS_OWNER** | • Failure: When access control enforcement
fails • Success : When access control enforcement is granted |
`'failure'` | No bypass for failure case |
| **UPDATE_OBJECTS_ACCESS_MODE** | • Failure: When access control
enforcement fails • Success : When access control enforcement is granted
| `'failure'` | No bypass for failure case |



## Logging scenarios

### 1. AccessControl is modified by owner
- Expected outcome: Auhorized
- Log example: 
```
{"event":{"action":"saved_object_update_objects_access_mode","category":["database"],"type":["change"],"outcome":"success"},"kibana":{"space_id":"default","session_id":"ujNfHZISIOS99ANjQxxSAjxtY+D0D0/VnGBDDIJG5IE=","saved_object":{"type":"access_control_type","id":"bdceca5c-cd1e-442e-8272-55c632398cc7"}},"user":{"id":"u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0","name":"elastic","roles":["superuser"]},"trace":{"id":"3df30168-353c-48ee-b0ee-bb671f40d9f4"},"client":{"ip":"127.0.0.1"},"service":{"node":{"roles":["background_tasks","ui"]}},"ecs":{"version":"9.0.0"},"@timestamp":"2025-10-29T17:26:38.099+01:00","message":"User has updated access mode of access_control_type [id=bdceca5c-cd1e-442e-8272-55c632398cc7]","log":{"level":"INFO","logger":"plugins.security.audit.ecs"},"process":{"pid":42523,"uptime":107.077655333},"span":{"id":"2bf159f3eed6e557"}}
```
### 2. AccessControl is modified by non-owner
- Expected outcome: Unauthorized
- Log example:
```
{"event":{"action":"saved_object_update_objects_access_mode","category":["database"],"type":["change"],"outcome":"failure"},"kibana":{"space_id":"default","session_id":"Mn8eOxtCD7MOGlCPR4tRdfDpsKNSTXzVfXIAmzFNNsc=","unauthorized_spaces":["default"],"unauthorized_types":["access_control_type"]},"error":{"code":"Error","message":"Unable to manage_access_control for types access_control_type"},"user":{"id":"u_EWATCHX9oIEsmcXj8aA1FkcaY3DE-XEpsiGTjrR2PmM_0","name":"test_user","roles":["editor"]},"trace":{"id":"c3497d4e-3a93-4d3b-8800-6062009f1b77"},"client":{"ip":"127.0.0.1"},"service":{"node":{"roles":["background_tasks","ui"]}},"ecs":{"version":"9.0.0"},"@timestamp":"2025-10-29T17:44:28.893+01:00","message":"Failed attempt to update access mode of saved objects","log":{"level":"INFO","logger":"plugins.security.audit.ecs"},"process":{"pid":42523,"uptime":1177.897796208},"span":{"id":"912b67ee3b28dd14"}}
```

### 3. AccessControl is modified by admin on non-admin owned object 
- Expected outcome: Authorized
- Logs:
```
{"event":{"action":"saved_object_update_objects_access_mode","category":["database"],"type":["change"],"outcome":"success"},"kibana":{"space_id":"default","session_id":"ujNfHZISIOS99ANjQxxSAjxtY+D0D0/VnGBDDIJG5IE=","saved_object":{"type":"access_control_type","id":"03eb9fcd-584b-4766-bfba-327a4f0f696c"}},"user":{"id":"u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0","name":"elastic","roles":["superuser"]},"trace":{"id":"c6005951-24e4-4142-b13d-8bf101ca5468"},"client":{"ip":"127.0.0.1"},"service":{"node":{"roles":["background_tasks","ui"]}},"ecs":{"version":"9.0.0"},"@timestamp":"2025-10-29T18:09:28.253+01:00","message":"User has updated access mode of access_control_type [id=03eb9fcd-584b-4766-bfba-327a4f0f696c]","log":{"level":"INFO","logger":"plugins.security.audit.ecs"},"process":{"pid":61700,"uptime":596.898178917},"span":{"id":"b2b8a487b44915c8"}}
```


### Steps to test using integration tests

You can use the existing integration tests for access control by adding
the following to the config file at:
`x-pack/platform/test/spaces_api_integration/access_control_objects/config.ts`

Config to add at `kbnTestServer.serverArgs`

```
...
'--xpack.security.audit.enabled=true',
'--xpack.security.audit.appender.type=file',
'--xpack.security.audit.appender.fileName=./kibana_audit.log',
 '--xpack.security.audit.appender.layout.type=json'
```

Once you run the tests as 
```
no
de scripts/functional_tests_server --config x-pack/platform/test/spaces_api_integration/access_control_objects/config.ts
```

You can perform the tests as described here
#224411 and verify the logs in
`kibana_audit.log` file generated at the root level of your kibana
repository.

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
jeramysoucy added a commit that referenced this pull request Oct 30, 2025
…n bulk operations (#238468)

## Summary

This PR is a follow-up to #224411
(CRUD operations). It implements granular results for bulk operations
performed on object types supporting access control.

### What does this mean?

Traditionally, bulk operations throw if any type is not authorized for
the action in the current space. With access control, we're introducing
a new per-object authorization paradigm. A user may be authorized to
perform an action for a type in the current space, but one of the
objects requested may be in "write-restricted" mode (formerly
"read-only"). Objects in "write-restricted" mode can only be
overwritten, updated, or deleted if they are owned by the user
performing the operation or the user is an "Admin"™️ (`*` privilege in
the current space, granting the `manage_access_control` privilege).

The CRUD PR implemented all operations as authorized/unauthorized based
on whether the user performing the action was able to affect all
requested objects or not. This means that if just one object of 100 that
were being updated was in "write-restricted" mode, the operation would
throw an error an no objects would be updated, even if the user has
authorization to affect the other 99 objects. For import, this means
rejecting creation or overwrite of all objects in a given file by
throwing an error, due to the same reason.

### What this PR does, specifically

This PR changes authorization enforcement for access control by
granularly authorizing each individual object (of relevant types).
Non-bulk operations will behave the same way - if a user is not
authorized to perform the action on the object requested, the operation
will throw. Bulk operations behave differently:
- They will only **throw** if:
- ALL of the requested access control objects are unauthorized, **OR**
- If any requested types are unauthorized by RBAC (existing behavior)
- They will respond with **success and status** for each object if:
- Some of the requested access control objects are authorized **AND**
    - All of the requested types are authorized by RBAC

This affects only bulk operations that authorize access control objects
(with the exception of changing access control):
- Bulk Create
- Bulk Update
- Bulk Delete


### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [X] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This was checked for breaking HTTP API changes, and any breaking
changes have been approved by the breaking-change committee. The
`release_note:breaking` label should be applied in these situations.
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
- [ ] Review the [backport
guidelines](https://docs.google.com/document/d/1VyN5k91e5OVumlc0Gb9RPa3h1ewuPE705nRtioPiTvY/edit?usp=sharing)
and apply applicable `backport:*` labels.

### Risks

There is risk of introducing regressions to authorization for SOR
operations.
- Severity: high - any regression in the SOR is not tolerable
- Mitigation: we will ensure there are existing tests that adequately
check RBAC for SO's independently of access control, and that these
tests all pass
- Stakeholders: Core, all consumers of SOs (response ops, fleet, etc.)

---------

Co-authored-by: Sid <siddharthmantri1@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Aleh Zasypkin <aleh.zasypkin@gmail.com>
Co-authored-by: Christiane (Tina) Heiligers <christiane.heiligers@elastic.co>
SiddharthMantri added a commit that referenced this pull request Nov 17, 2025
…228416)

Closes #221754

## Summary

This PR implements changes to support import and export of saved objects
that support access control features - namely, "write-restricted"
dashboards (formerly "read-only" dashboards).

This effort is broken down into 2 phases:
### Phase 1
Import will not apply the access control metadata from the imported
file. This metadata contains the owner and access mode for the object.
Instead, any new objects (that support access control) created as a
result of import will be owned by the importing user and will be in
default mode. If there is no active user profile (import called via
API), the object will be created with no access control metadata (no
owner or mode), but this can be assigned later by an admin. Any objects
being overwritten as a result of import will maintain existing ownership
and mode, but this can be changed later by the owner or an admin if
needed.

### Phase 2
Import will support applying the access control metadata from the
imported file, but only for "Admin" users (users with the
`manage_access_control` privilege). Admins will be able to specify an
option to enable this behavior. If there is no access control metadata
in the file for an existing object that will be overwritten, it will
maintain existing ownership and mode.

**This PR handles Phase 1, and Phase 2 will be handled in a follow-up
effort.**

### Feature Flag Note
The changes here do not check the `savedObjects.enableAccessControl`
config feature flag. The reasoning is that we still want to intercept
unexpected access control metadata that may be present in export/import
files and reject the import for any applicable objects. Alternatively,
we could check the feature flag, and if disabled, always strip any
incoming access control metadata.

## Details
### Import
Import relies on existing saved object bulk create functionality, which
is handled by #224411 and
#238468. Prior to bulk create, the
import operation filters invalid objects and responds with this
information to surface in the UI. Bulk create already handles object ID
clashes and authorization for overwriting access control objects.

When importing objects with access control, the access control metadata
from the import file will be ignored in order to preserve the owner and
access mode for any existing objects being overwritten.

Access control requires some new filtering capability - 
- In Phase 1 (this phase) objects that should not include access control
metadata
- In Phase 2, objects missing required parts of access control metadata
(only needed when we implement support for admins to import access
control metadata - phase 2)

Since Phase 1 does not support importing access control metadata, this
metadata will be stripped from incoming objects via another transform.
Because of that, the filter transform is not critical in Phase 1,
however, it implements the baseline for filtering needed in Phase 2, so
it was worth staging here.

To achieve the filtering and stripping, I added the ability to set
"access control transforms" with the saved object service. I created an
import transform factory that will create a filter stream and a map
streams (the transforms). This factory is set in the saved objects
service and passed to the of `SavedObjectsImporter` constructor. This
loosely follows the paradigm used to set the saved object repository
extensions.

The filter stream handles all of the filtering of invalid objects,
specific to access control, while the map stream handles removal of
access control metadata (in Phase 1 for all users, in Phase 2 for
non-admin users or when an admin has not specified the option to import
access control metadata).

The end result - if the factory is defined, the import transform streams
are generated during import, and executed inline with the other filter
streams during the import process. After the invalid access control
objects are filtered out, we rely on the bulk create operation to handle
the rest.

### Export
Originally during export we had planned to strip the access control
metadata, but the design was revised. Keeping access control metadata in
the export files means that they will contain everything necessary for
import after Phase 2. Because of this, not export transforms are
currently required.

We _could_ implement a transform to strip the access control metadata
when the access control feature flag is disabled, but this is not
necessary - the additional metadata is not harmful or affective in Phase
1. We do not expect objects to contain metadata until the feature flag
is enabled, and we do not expect the flag to be toggled back and forth.

### Testing
This PR include both unit and integration tests for import, export, and
resolve import errors APIs.

### Risk
This functionality will sit behind a feature flag (to be implemented).
If the feature is disabled, the transforms will not get set with the
saved object service, leaving import and export to function as they did
prior to this PR - verified by the existing suites of unit and
integration tests.

#### Known issue
If an export file contains access control metadata and is imported when
the feature flag is disabled, import will surface an error for each
object that contains the metadata, stating that the object contains
unexpected metadata. While we do not expect this to happen, if it ever
does, removing the metadata from an export file is fairly simple and
mitigates the issue.

#### Approach
As an alternative, the transforms can be moved inline with the rest of
the importer and exporter codebase without significant effort.

---------

Co-authored-by: Sid <siddharthmantri1@gmail.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Aleh Zasypkin <aleh.zasypkin@gmail.com>
Co-authored-by: Christiane (Tina) Heiligers <christiane.heiligers@elastic.co>
SiddharthMantri added a commit that referenced this pull request Dec 12, 2025
Closes elastic/kibana-team#808

The respective teams have been raising PRs against this feature branch.
Approved PRs merged so far:
- #221916
- #224411 
- #239973 
- #241101
- #238468
- #233552
- #228416 
- #241168
- #244746
- #244830

## Summary
This pull request overhauls the saved object management workflow by
introducing the concept of ownership for SOs - specifically enabled for
dashboards only at the moment. Owners and administrators can now control
a new write-restricted flag on their objects, allowing them to keep work
draft/uneditable state before publishing. This change enables users to
define who can modify shared objects, providing a crucial capability to
manage and share dashboards.


## Release note
Kibana Dashboards now support ownership and "write_restricted" mode.
Users can now keep dashboards publicly editable or in a write-restricted
state until they are ready to publish, giving them more control over who
can edit their dashboards, regardless of broader space permissions.


## How to test

### Serverless
Please reach out to me via slack or in the project channel
(#read-only-dashboards) to be invited to the serverless environment
where this feature has been enabled.

### Local

- Clone this PR
- Enable the feature by editing kibana.yml to include 
```
savedObjects.enableAccessControl: true
```
- Start ES and Kibana as you would
- Once started, seed Kibana with sample data. This should create a few
dashboards.
- Navigate to dashboards and create a new one.
- In the share modal, change the view mode `Everybody in the space Can
View`,
<img width="500" height="410" alt="image"
src="https://github.com/user-attachments/assets/b895442f-cce3-41a6-8b47-d206a9afbf43"
/>

- Now create a new role which grants access to indices and dashboards
all. Create a new user and then assign that role to the newly created
user.
<img width="500" height="410" alt="image"
src="https://github.com/user-attachments/assets/dd5251e1-a3b5-41a8-abc1-7e67399d65d2"
/>

- Login as the new user and navigate to the dashboard you had initially
set as `Can view`. You'll see that you're not able to edit the dashboard
and a warning like
<img width="500" height="410" alt="Screenshot 2025-11-28 at 12 30 50"
src="https://github.com/user-attachments/assets/1f71ccc7-9dc6-4a68-9a2c-540aa74e4f03"
/>



###  Local (2nd option)

You can also follow the instructions in
#224411 that detail how to use
the funtional test runner to test this using the test plugin created for
this feature.

### Risk matrix

- What happens when your feature is used in a non-default space or a
custom
  space? Works as expected
- What happens when there are multiple Kibana nodes using the same
Elasticsearch
  cluster? Does not depend on functionality of kibana nodes
- What happens when a plugin you depend on is disabled? Changes are in
core and security - both are always available
- What happens when a feature you depend on is disabled? No dependency
- What happens when a third party integration you depend on is not
responding? No third party inregration
- Does the feature work in Elastic Cloud? Yes
- Does the feature create a setting that needs to be exposed, or
configured
  differently than the default, on the Elastic Cloud? No
- Is there a significant performance impact that may affect Cloud Kibana
  instances? No
- Does your feature need to be aware of running in a container? No
- Does the feature Work with security disabled, or fails gracefully? If
disabled, fails gracefully.
- Are there performance risks associated with your feature? No
- Could this cause memory to leak in either the browser or server? No
- Will your feature still work if Kibana is run behind a reverse proxy?
Yes
- Does your feature affect other plugins? No, other plugins could choose
to use it if registering a SO with ownable types
- Are migrations handled gracefully? Does the feature affect old indices
or saved objects? Yes, migrations taken care of.
- Are you using any technologies, protocols, techniques, conventions,
libraries, NPM modules, etc. that may be new or unprecedented in Kibana?
No

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jeramy Soucy <jeramy.soucy@elastic.co>
Co-authored-by: Aleh Zasypkin <aleh.zasypkin@gmail.com>
Co-authored-by: Christiane (Tina) Heiligers <christiane.heiligers@elastic.co>
Co-authored-by: Krzysztof Kowalczyk <krzysztof.kowalczyk@elastic.co>
Co-authored-by: Gerard Soldevila <gerard.soldevila@elastic.co>
seanrathier pushed a commit to seanrathier/kibana that referenced this pull request Dec 15, 2025
Closes elastic/kibana-team#808

The respective teams have been raising PRs against this feature branch.
Approved PRs merged so far:
- elastic#221916
- elastic#224411 
- elastic#239973 
- elastic#241101
- elastic#238468
- elastic#233552
- elastic#228416 
- elastic#241168
- elastic#244746
- elastic#244830

## Summary
This pull request overhauls the saved object management workflow by
introducing the concept of ownership for SOs - specifically enabled for
dashboards only at the moment. Owners and administrators can now control
a new write-restricted flag on their objects, allowing them to keep work
draft/uneditable state before publishing. This change enables users to
define who can modify shared objects, providing a crucial capability to
manage and share dashboards.


## Release note
Kibana Dashboards now support ownership and "write_restricted" mode.
Users can now keep dashboards publicly editable or in a write-restricted
state until they are ready to publish, giving them more control over who
can edit their dashboards, regardless of broader space permissions.


## How to test

### Serverless
Please reach out to me via slack or in the project channel
(#read-only-dashboards) to be invited to the serverless environment
where this feature has been enabled.

### Local

- Clone this PR
- Enable the feature by editing kibana.yml to include 
```
savedObjects.enableAccessControl: true
```
- Start ES and Kibana as you would
- Once started, seed Kibana with sample data. This should create a few
dashboards.
- Navigate to dashboards and create a new one.
- In the share modal, change the view mode `Everybody in the space Can
View`,
<img width="500" height="410" alt="image"
src="https://github.com/user-attachments/assets/b895442f-cce3-41a6-8b47-d206a9afbf43"
/>

- Now create a new role which grants access to indices and dashboards
all. Create a new user and then assign that role to the newly created
user.
<img width="500" height="410" alt="image"
src="https://github.com/user-attachments/assets/dd5251e1-a3b5-41a8-abc1-7e67399d65d2"
/>

- Login as the new user and navigate to the dashboard you had initially
set as `Can view`. You'll see that you're not able to edit the dashboard
and a warning like
<img width="500" height="410" alt="Screenshot 2025-11-28 at 12 30 50"
src="https://github.com/user-attachments/assets/1f71ccc7-9dc6-4a68-9a2c-540aa74e4f03"
/>



###  Local (2nd option)

You can also follow the instructions in
elastic#224411 that detail how to use
the funtional test runner to test this using the test plugin created for
this feature.

### Risk matrix

- What happens when your feature is used in a non-default space or a
custom
  space? Works as expected
- What happens when there are multiple Kibana nodes using the same
Elasticsearch
  cluster? Does not depend on functionality of kibana nodes
- What happens when a plugin you depend on is disabled? Changes are in
core and security - both are always available
- What happens when a feature you depend on is disabled? No dependency
- What happens when a third party integration you depend on is not
responding? No third party inregration
- Does the feature work in Elastic Cloud? Yes
- Does the feature create a setting that needs to be exposed, or
configured
  differently than the default, on the Elastic Cloud? No
- Is there a significant performance impact that may affect Cloud Kibana
  instances? No
- Does your feature need to be aware of running in a container? No
- Does the feature Work with security disabled, or fails gracefully? If
disabled, fails gracefully.
- Are there performance risks associated with your feature? No
- Could this cause memory to leak in either the browser or server? No
- Will your feature still work if Kibana is run behind a reverse proxy?
Yes
- Does your feature affect other plugins? No, other plugins could choose
to use it if registering a SO with ownable types
- Are migrations handled gracefully? Does the feature affect old indices
or saved objects? Yes, migrations taken care of.
- Are you using any technologies, protocols, techniques, conventions,
libraries, NPM modules, etc. that may be new or unprecedented in Kibana?
No

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jeramy Soucy <jeramy.soucy@elastic.co>
Co-authored-by: Aleh Zasypkin <aleh.zasypkin@gmail.com>
Co-authored-by: Christiane (Tina) Heiligers <christiane.heiligers@elastic.co>
Co-authored-by: Krzysztof Kowalczyk <krzysztof.kowalczyk@elastic.co>
Co-authored-by: Gerard Soldevila <gerard.soldevila@elastic.co>
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.