---
title: "Owners and permissions — Imports for Laravel | Roundly"
description: "The owner as a security boundary — Ownership rules, the OwnerScope contract, the teams-for-laravel recipe, authorize() on every step and audit recipes."
url: https://roundly-consulting.com/open-source/docs/imports-for-laravel/owners-and-permissions
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Imports for Laravel](https://roundly-consulting.com/open-source/docs/imports-for-laravel.md)

# Owners and permissions

Every import runs for an owner — a team, a company, or the user in a single-tenant app — and every lookup and write stays inside it: natural-key matches, reference lookups, the records an import creates or updates, and what undo loads.

```php
$imports = Imports::for($team)->by($user);

$imports->session($id);            // another owner's id → ImportNotFoundException (404)
$imports->batch($id);
$imports->sessions('assets', SessionStatus::Previewed)->paginate();   // newest first
$imports->running('assets');       // ?SessionHandle — committing or rolling back

Imports::session($id);             // trusted path: no owner check — never from HTTP code
Imports::batch($id);
```

Imports::session() and ::batch() without an owner are the trusted PHP path; HTTP code never uses them.

## Ownership — declared, fail closed

```php
use RoundlyConsulting\Imports\Definition\Ownership;

Ownership::column('team_id');           // where team_id = owner key; new records: $context->ownerAttributes()
Ownership::belongsTo('team');           // whereBelongsTo($owner, 'team')
Ownership::morph('owner');              // owner_type + owner_id
Ownership::using(
    query: fn (Builder $q, Model $owner) => $q->whereHas('project', fn ($p) => $p->whereBelongsTo($owner)),
    owns: fn (Model $record, Model $owner) => $record->project->team_id === $owner->getKey(),
);
Ownership::global();                    // the explicit opt-out
```

- Ownership::inherit() is a reference field’s default — the importer’s rule.
- Each rule offers attributes(Model $owner) — what ImportContext::ownerAttributes() returns: \['team\_id' => 7\] for a column, the <name>\_type / <name>\_id pair for a morph, \[\] otherwise — plus isGlobal() and describe().
- The importer’s rule constrains natural-key matches and rollback loads, and after every create or update OwnerScope::owns() must accept the record, or the row fails as outside\_owner.

## OwnerScope

```php
interface OwnerScope
{
    public function run(Model $owner, Closure $callback): mixed;                     // ambient context
    public function constrain(Builder $query, Model $owner, Ownership $rule): Builder;
    public function owns(Model $record, Model $owner, Ownership $rule): bool;
}
```

imports.owner\_scope defaults to Scoping\\ExplicitOwnerScope: no ambient context — run() just calls back — and it applies the declared rules (the column, whereBelongsTo, the morph pair, your using() closures, or nothing for global()). A using() closure is applied inside a nested where, and whatever a custom scope’s constrain() adds is grouped as one condition, so an orWhere in it can never widen a lookup. Every pipeline step that reads your data and every background entry point — the preview job, chunk and rollback jobs, the batch callbacks — runs inside run($owner).

## Recipe: teams-for-laravel

teams-for-laravel is optional. With it, run every import inside the owner team:

```php
use RoundlyConsulting\Imports\Contracts\OwnerScope;
use RoundlyConsulting\Imports\Definition\Ownership;
use RoundlyConsulting\Imports\Scoping\ExplicitOwnerScope;
use RoundlyConsulting\Teams\Facades\Teams;
use RoundlyConsulting\Teams\Models\Team;

final class TeamsOwnerScope implements OwnerScope
{
    public function run(Model $owner, Closure $callback): mixed
    {
        if (! $owner instanceof Team) {
            throw new InvalidArgumentException('Imports owned by teams need a team as their owner.');
        }

        return Teams::runAs($owner, static fn (): mixed => $callback());
    }

    public function constrain(Builder $query, Model $owner, Ownership $rule): Builder
    {
        return (new ExplicitOwnerScope)->constrain($query, $owner, $rule);
    }

    public function owns(Model $record, Model $owner, Ownership $rule): bool
    {
        return (new ExplicitOwnerScope)->owns($record, $owner, $rule);
    }
}
```

```php
// config/imports.php
'owner_scope' => App\Imports\TeamsOwnerScope::class,
'routes' => ['owner_resolver' => App\Imports\CurrentTeamOwner::class],   // ResolvesImportOwner → the current team
```

Inside Teams::runAs() the teams package’s TeamScope and TeamWriteGuard isolate every Eloquent read and write, and the import owner’s team replaces whatever team a queued job’s dispatcher had. This exact class is tested against the real teams package.

## Authorization

authorize(?Authenticatable $user, Model $owner, ImportOperation $operation) is required — fail closed. Every handle operation asks it for the handle’s actor: reads as View, writes as their own operation.

```php
public function authorize(?Authenticatable $user, Model $owner, ImportOperation $operation): bool
{
    return match ($operation) {
        ImportOperation::View => $user?->can('view-imports', $owner) ?? false,
        ImportOperation::Upload, ImportOperation::Commit => $user?->can('import-assets', $owner) ?? false,
        ImportOperation::Rollback => $user?->can('undo-imports', $owner) ?? false,
    };
}

// or a Gate one-liner
return $user?->can('import-assets', $owner) ?? false;
```

- Upload — at upload, before the file is read or stored, and for mapping, mode, source, preview and saved-mapping changes.
- Commit — at commit and resume, and again when each run of the batch starts; a committer who lost access fails the batch (unauthorized).
- Rollback — at undo.
- View — every read: status, sheets, headers, counts, progress, preview data, rows, the mapping suggestion and the error report on a session; status, progress, summary, records, canRollback, the rollback report and the predicates on a batch; the session list, running(), saved mappings and importers()->available(). sessions() without an importer lists only importers whose authorize() allows View.

A null actor (no by(), or Imports::session() / ::batch()) is system code: your authorize() decides what it may do — there is no hidden bypass. The verdict is cached per handle instance and operation, so a polling loop on one handle asks once.

## Audit recipes

Every import event carries an ImportAudit — session, batch, importer, owner, the actor of that operation, the file reference and the counts:

```php
use Illuminate\Support\Facades\Event;
use RoundlyConsulting\Imports\Events\ImportCompleted;

Event::listen(fn (ImportCompleted $e) => AuditLog::create([
    'action' => 'import.completed', 'team_id' => $e->audit->owner->getKey(), 'user_id' => $e->audit->actor?->getKey(),
    'details' => ['importer' => $e->audit->importer, 'file' => $e->audit->file->originalName, 'created' => $e->summary->created],
]));
```

With sentinel-for-laravel (optional), seal the imported records from the ledger in an ImportCompleted listener ($batch->records(RecordAction::Created)) or record the event in your own sealed log.

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/imports-for-laravel.md)
