Skip to main content

Role-Based Access for a Lead-List Workspace: Owners, Operators and Viewers

Updated 6 min readMarkdown

I work on Locate Next, so I will note where this matches our product and where it does not.

A shared folder of lead lists starts simple. One person uploads a CSV, another runs the search, a third downloads the export into the CRM. Then the team grows and an export ends up somewhere nobody can explain. That is when access control stops being optional.

This post walks through a small role model for a workspace that holds lead lists, with a short Python sample. It is meant for people building or configuring workspace tooling.

What makes lead-list data different

Lead lists contain names, phone numbers and emails of real people. A few things follow:

  • Export is the most sensitive action. Viewing a result inside the app is lower risk. Downloading a file of contacts is where data leaves your control.
  • Search can spend money. In many skip tracing tools, running a search has a cost, so "who can search" is a budget question too.
  • Uploads are untrusted input. A spreadsheet from a county site or a broker can contain formulas. Read every upload as data, never run it.
  • Teams must not see each other's lists. Isolation between workspaces matters more than any single role.

Three roles are usually enough

  • Owner. Everything an operator can do, plus managing who is in the workspace.
  • Operator. Uploads lists, runs searches, makes review decisions, exports files.
  • Viewer. Reads results. Cannot upload, search, export or change anything.

The viewer role is easy to skip, and it is often the one a growing team needs first. Be clear about what it does and does not do. A viewer can still read and screenshot whatever is on screen. Viewer means no export, not no access to the data, so give viewer access only to people who should see the contacts.

A permission map and a check function

Keep permissions as data, not scattered if statements. Save the first block as roles.py.

from datetime import datetime, timezone
from enum import Enum


class Action(Enum):
    VIEW = "view"
    UPLOAD = "upload"
    SEARCH = "search"
    EXPORT = "export"
    EDIT = "edit"  # review decisions and row changes
    MANAGE_MEMBERS = "manage_members"


ROLE_PERMISSIONS = {
    "owner": {Action.VIEW, Action.UPLOAD, Action.SEARCH, Action.EXPORT,
              Action.EDIT, Action.MANAGE_MEMBERS},
    "operator": {Action.VIEW, Action.UPLOAD, Action.SEARCH, Action.EXPORT,
                 Action.EDIT},
    "viewer": {Action.VIEW},
}


def can(member, action, workspace_id, log):
    """Allow or deny one action, and record the decision either way."""
    member = member if isinstance(member, dict) else {}
    same_workspace = (workspace_id is not None
                      and member.get("workspace_id") == workspace_id)
    allowed = (isinstance(action, Action) and same_workspace
               and action in ROLE_PERMISSIONS.get(member.get("role"), set()))
    log.append({
        "at": datetime.now(timezone.utc).isoformat(),
        "user": member.get("user"),
        "action": action.value if isinstance(action, Action) else repr(action),
        "workspace": workspace_id,
        "allowed": allowed,
    })
    return allowed

Design choices worth calling out:

  1. Workspace check first. A member of one workspace gets nothing in another, whatever their role.
  2. Bad input is denied, not a crash. An unknown or missing role, an action that is not an Action, or a missing member all return False and still get logged.
  3. Every decision goes in the audit log, including denials, with a UTC timestamp in at.
  4. EDIT covers review decisions and row changes, such as picking a person or correcting a row.

This sample keeps one workspace on each member to stay short. In production, store a membership per workspace, so one person can hold different roles in different workspaces.

Testing it with made-up data

The test below uses fictional members and example.com addresses, including bad inputs:

from roles import Action, can

log = []
ana = {"user": "ana@example.com", "role": "owner", "workspace_id": "ws_east"}
ben = {"user": "ben@example.com", "role": "operator", "workspace_id": "ws_east"}
cy = {"user": "cy@example.com", "role": "viewer", "workspace_id": "ws_east"}
dee = {"user": "dee@example.com", "role": "intern", "workspace_id": "ws_east"}
eli = {"user": "eli@example.com", "workspace_id": "ws_east"}  # no role

assert can(ana, Action.MANAGE_MEMBERS, "ws_east", log)
assert can(ben, Action.EXPORT, "ws_east", log)
assert not can(ben, Action.MANAGE_MEMBERS, "ws_east", log)
assert can(cy, Action.VIEW, "ws_east", log)
for a in (Action.UPLOAD, Action.SEARCH, Action.EXPORT, Action.EDIT):
    assert not can(cy, a, "ws_east", log)
assert not can(ana, Action.VIEW, "ws_west", log)   # other workspace
assert not can(dee, Action.VIEW, "ws_east", log)   # unknown role
assert not can(eli, Action.VIEW, "ws_east", log)   # missing role
assert not can(ana, "export", "ws_east", log)      # string, not Action
assert not can(None, Action.VIEW, "ws_east", log)  # no member at all
assert len(log) == 13 and sum(e["allowed"] for e in log) == 3
assert all(e["at"].endswith("+00:00") for e in log)
print("ok", len(log), "decisions,", sum(e["allowed"] for e in log), "allowed")

It prints ok 13 decisions, 3 allowed.

Beyond the role map

  • Build exports on request and do not cache them. With no cached copy, there is nothing stored to leak or to serve stale. Who gets a file is decided by the permission check at the moment it is built.
  • Fingerprint exports. A leaked copy can often be traced back to the export that produced it. Fingerprints deter casual sharing. They do not stop deliberate exfiltration.
  • Neutralize formulas. Values starting with =, +, -, @, a tab or a carriage return can run as formulas in a spreadsheet. Prefixing them with an apostrophe keeps them as text. The trade-off is that the apostrophe can show up in the value when another tool reads the file, so document it for whoever imports it.
  • Keep the audit log readable. Who uploaded, searched, exported and when.

How Locate Next documents this

The model above is close to how Locate Next works; here is what its security page documents. It says there is one workspace per team and "nothing reads across workspaces." It lists three roles: "Owners and operators can upload, search and export. Viewers can look at results but can't upload, search, export or change anything." Uploads, searches, company decisions, picks and exports "are logged with who did them," and each export is "built on request from your workspace, fingerprinted, and never cached." Anything that could run as a formula "gets a leading apostrophe."

The page does not document how owner permissions differ from operator permissions, so the member management split in my sample is my own illustration. The page says automated tests cover these controls, and that no independent audit has been done yet. Read the full wording on the Locate Next security page.

Wrapping up

Start with workspace isolation, keep roles few and stored as data, deny bad input, and log every decision. That gets a small team most of the way to safer sharing of lead lists.

About the author

  • Skip trace any property list or find homeowners by zip, city or state. Review matches and export only the contacts you choose to your CRM.

No comments yet. Be the first to comment.