How to allow access to records for a "proxy" user?

In every business, executives have Executive Admins or Personal Admins or Secretaries or whatever they may be called - but in every case they are someone who acts on behalf of another person - what I’ll call a “proxy”.

As far as i can tell, there is no way to create a “proxy” relationship in Knack. There is no way to to say “Jim Smith can also be known as Michelle Jones”. If something is owned by Jim Smith, or assigned to Jim Smith, or created by Jim Smith (or whatever relationship field is being used to filter a view), then there is no simple way to allow someone who isn’t Jim Smith but works on behalf of Jim Smith to see those records in a view.

I’ve toyed with ideas like establishing a proxy account connection field in every table and using record rules to set up the proxy relationship when records are created or updated, but that is somewhat static - when an EA gets sick or takes a vacation, it would require a wholesale replacement of the EA name with their temp replacement, then change it all back when they return. And what about where a pool of EAs operate across a pool of execs? Too much work.

So my question is: has anyone else found a way to resolve this challenge?

I did create somewhat of a solution in the past that might work for you. My use case was trying to view individual’s actual Annual Performance Plans exactly how they would see it with their data. It was easy for a supervisor because I had a two step relationship logged-in user>supervisor position>plan - easy to create a table for them to see all plans connected to their team members. But it was not easy for an HR Business Partner that we wanted to allow full visibility to support team members by looking at exactly what they saw. So we created a table for admins showing all plans, and then lots of links in that table that opened the edit pages that end users used.

As an extension to this - if your EAs are connected a person they support, you could have a connection from an EA user>EA position (incumbent user)>Executive position (support EA). Then create a table grid for a logged in EA to see the Executive position they support via logged-in user>EA position>Executive Positions. Then in that grid show the position and the Executive Position’s ‘current incumbent’ (an account connection) - with a link to a page of details about that Account. Then on that ‘Account’ page - create a bunch of grids/lists of things assigned to that user (e.g. tasks, actions) together with links to the forms the Executive users to actually take the action. You could also add reports they see together with the click-throughs to take action. That account page is basically mimicing what a user gets with grids/lists connected to the logged in user, and you have a gated way to get to it.

Of course anyone who finds the ID of the Executive could in theory open said page - but you could restrict access to it to an EA role.

What do you think?

Thanks Grant, your detailed response got me thinking about how I might approach this. I then realised it is pretty simple. So here is the approach that I took, in some detail, for anyone else who wants to do the same.

First, I created a new field in ACCOUNTS, a 1-many connection to itself (ACCOUNTS), called ALLOWED DELEGATES. I then created a form accessed from Account Settings to allow anyone to edit this field in their own (logged in user) account record, to add as many allowed delegates as they like.

In my example scenario, Jim Smith logs in and sets this field to Michelle Jones. This is the OFFER side of the delegation relationship …

(Note that I also created a Delegate user role which I give to Michelle Jones, and the selection is filtered by user role contains “Delegate”, so this limits who can be a delegate. But the main reason for the new user role is to provide access to a DELEGATE menu item, discussed below).

Second, I created another new field in ACCOUNTS, a 1-many connection to ACCOUNTS, called ACTIVE DELEGATIONS. I then created another form to allow anyone to edit this field in their own (logged in user) account. It is set up to only show those people who have made the “Offer” using the following restriction:

image

This way Michelle can ACCEPT to be a delegate for anyone who has allowed her to be one - ensuring its a two way agreement to act as a delegate. Once Michelle selects Jim and submits, she creates an ACTIVE DELEGATION.

To be able to use that Active Delegation, I created a DELEGATE menu with copies of the various scenes that I want a delegate to be able to see, for example a grid set up as:

This grid allows Michelle to see everything that Jim is responsible for - and anyone else who Michelle acts is an active delegate for.

THE ONLY HOLE IN THIS APPROACH is that Jim has no way to cleanly revoke Michelle’s access.

Taking away the “Allow” doesn’t impact the “Active” status. I can’t do it using record rules because that would remove every delegation from Michelle - because using record rules its all or nothing - there is no selective removal. Plus if Jim had more than one delegate, it would remove them too because the record rules will update EVERY delegate…

So instead I put up a big warning to Jim when he changes the “Allowed” settings to tell him to have the system admin remove Michelle’s active status - it could of course be an automated email to the system admin requesting the same.

If anyone has any thoughts about how to resolve this final piece of the puzzle I’m all ears.

OK, so Claude and I (ok, it was all Claude) sorted out the JS to remove the delegation when the “exec” decides to revoke it. Here is the code HEAVILY commented, hopefully it can help someone one day. Reach out of still unclear. Works perfectly. Case now closed.

/* =====================================================================
   RECIPROCAL DELEGATION REVOCATION
   =====================================================================
   PURPOSE:
   ACCOUNTS is connected to ACCOUNTS twice, forming an offer/accept
   delegation model:
     - field_978 "ALLOWED DELEGATES"    -> lives on the DELEGATOR's
       record ("ME"). This is the OFFER: a list of people ME is
       willing to let act as delegate.
     - field_965 "ACTIVE DELEGATIONS"   -> lives on the DELEGATE's
       record ("HER"). This is the ACCEPTANCE: HER choice to actually
       activate one of the offers made to her (selected from a filtered
       list where field_978 contains HER).

   THE PROBLEM:
   These two fields live on two different records and are updated
   independently. If ME removes HER from field_978 (revoking the
   offer), field_965 on HER record still contains ME — so she retains
   access to ME's data even though the offer was withdrawn. Knack
   record rules can only SET a connection field wholesale; there's no
   native "remove just this one value" action, so this can't be fixed
   with record rules alone.

   THE FIX:
   Watch ME's edit of field_978. When we detect delegate(s) were
   removed (via before/after diff, since Knack doesn't tell us "what
   changed", only "what it is now"), for each removed delegate we go
   fetch THEIR record and strip ME's ID back out of their field_965.

   DELIBERATELY ONE-DIRECTIONAL:
   If HER instead deselects herself from field_965 (i.e. "I don't want
   to act on this delegation right now"), that does NOT touch ME's
   field_978 offer. The offer and the acceptance are treated as
   distinct, independently revocable choices — e.g. she might step
   away for parental leave and come back later without ME needing to
   re-offer. Only a delegator-side removal cascades.

   INFRASTRUCTURE REQUIRED:
   - scene_372 / view_872: ME's edit screen/form for field_978.
     This is where the snapshot + change-detection happens.
   - scene_347 / view_877 "Accounts for Revoking Delegation": a grid
     view, filtered to show only Accounts where field_965 contains the
     logged-in user. Because Knack's page-context API endpoint only
     allows writes through a view that itself has write capability,
     INLINE EDITING was switched on for field_965 on this view purely
     to grant that capability — the JS below does raw API calls and
     never actually touches the inline-edit UI directly.
   ===================================================================== */

$(document).on('knack-scene-render.scene_372', function() {

  // Knack's built-in helper — gives us the record ID of whoever is
  // currently logged in. This IS the "ME" record ID throughout.
  const myRecordId = Knack.getUserAttributes().id;

  // This holds the list of delegate IDs from BEFORE the most recent
  // save, so we can diff against what it becomes AFTER a save and
  // figure out who was removed. Starts empty; populated by the
  // snapshot function immediately on page render.
  let previousDelegateIds = [];

  /* ---------------------------------------------------------------
     STEP 1: SNAPSHOT
     On page render, fetch ME's own record fresh from the API and
     record which delegate IDs are currently in field_978. We can't
     rely on Knack's client-side cached view data because we need the
     _raw connection format (array of {id, identifier} objects) to
     compare IDs reliably.
     --------------------------------------------------------------- */
  function snapshotCurrentDelegates() {
    $.ajax({
      // Page-context endpoint pattern (object-level endpoints 403 in
      // this app, per established RAIDDAR API access pattern).
      url: `https://api.knack.com/v1/pages/scene_372/views/view_872/records/${myRecordId}`,
      type: 'GET',
      headers: {
        'X-Knack-Application-Id': Knack.application_id,
        'Authorization': Knack.getUserToken()
      },
      success: function(record) {
        // field_978_raw is an array of {id, identifier} objects for
        // a connection field. We only need the IDs for comparison,
        // so map down to a plain array of ID strings.
        previousDelegateIds = (record['field_978_raw'] || []).map(c => c.id);
      }
      // No error handler here deliberately — if this fails, worst
      // case previousDelegateIds stays [], meaning the very next
      // diff would treat ALL current delegates as "removed" and
      // wrongly try to revoke everyone. Consider adding a retry or
      // alert here if this proves to be a real risk in practice.
    });
  }
  snapshotCurrentDelegates();

  /* ---------------------------------------------------------------
     STEP 2: DETECT THE CHANGE
     This Knack event fires after view_872's record save completes
     (i.e. after ME edits and saves field_978). The `record` argument
     Knack passes in should reflect the NEW saved state.
     --------------------------------------------------------------- */
  $(document).on('knack-record-update.view_872', function(event, view, record) {

    // What field_978 looks like NOW, after the save.
    const newDelegateIds = (record['field_978_raw'] || []).map(c => c.id);

    // Anyone in the OLD list but not the NEW list was just removed.
    // This naturally supports removing multiple delegates in a single
    // save — removedIds could contain 0, 1, or many IDs.
    const removedIds = previousDelegateIds.filter(id => !newDelegateIds.includes(id));

    // For each person ME just revoked, go clean up their side.
    removedIds.forEach(function(delegateAccountId) {
      revokeReciprocalAccess(delegateAccountId, myRecordId);
    });

    // IMPORTANT: reset the baseline to the new state. Without this,
    // a second edit later in the same page-load would incorrectly
    // diff against the ORIGINAL list from page-load, not the most
    // recent saved state, and could try to "re-revoke" people who
    // were already handled.
    previousDelegateIds = newDelegateIds;
  });

  /* ---------------------------------------------------------------
     STEP 3: CASCADE THE REVOCATION
     Given one delegate's record ID, fetch their record via the
     utility grid endpoint, strip the revoking user's ID out of their
     field_965 (ACTIVE DELEGATIONS), and write it back — but only if
     something actually changed, to avoid a pointless PUT.
     --------------------------------------------------------------- */
  function revokeReciprocalAccess(delegateAccountId, revokedFromId) {

    // GET the delegate's record through view_877 (NOT view_872 — this
    // view is filtered to show accounts where field_965 contains the
    // logged-in user, and critically has inline-edit switched on for
    // field_965, which is what makes the PUT below actually work
    // rather than being rejected for lack of write permission).
    $.ajax({
      url: `https://api.knack.com/v1/pages/scene_347/views/view_877/records/${delegateAccountId}`,
      type: 'GET',
      headers: {
        'X-Knack-Application-Id': Knack.application_id,
        'Authorization': Knack.getUserToken()
      },
      success: function(delegateRecord) {

        // Delegate's current ACTIVE DELEGATIONS list (could contain
        // several other delegators too — we must only remove OUR ID,
        // not wipe the whole field).
        const currentActive = (delegateRecord['field_965_raw'] || []).map(c => c.id);

        // Remove just the revoking delegator's ID, keep everyone else
        // this delegate has an active delegation with.
        const updatedActive = currentActive.filter(id => id !== revokedFromId);

        // Only bother writing if the array actually shrank — avoids
        // an unnecessary API call if, for some reason, the ID wasn't
        // present (e.g. she'd already removed it herself).
        if (updatedActive.length !== currentActive.length) {
          $.ajax({
            url: `https://api.knack.com/v1/pages/scene_347/views/view_877/records/${delegateAccountId}`,
            type: 'PUT',
            headers: {
              'X-Knack-Application-Id': Knack.application_id,
              'Authorization': Knack.getUserToken(),
              'Content-Type': 'application/json'
            },
            // NOTE: writes take a plain array of ID strings, NOT the
            // {id, identifier} object shape that reads return. This
            // is a classic Knack gotcha — read shape != write shape.
            data: JSON.stringify({ field_965: updatedActive }),
            success: function() {
              console.log(`Revoked ${revokedFromId} from delegate ${delegateAccountId}'s ACTIVE DELEGATIONS`);
            },
            error: function(err) {
              // Silent failure would leave the delegate with lingering
              // access ME thinks was revoked — logging is the minimum
              // safety net; consider surfacing this to the user if it
              // starts happening in practice.
              console.error('Reciprocal revoke failed', err);
            }
          });
        }
      },
      error: function(err) {
        console.error('Could not fetch delegate record from view_877', err);
      }
    });
  }
});