#104proposal: fine-grained permissions at a module level

  • avatar~lino-levancommented · 2 years ago
    Author

    One of Deno's biggest misses was not figuring out https://github.com/denoland/deno/issues/378 in time. It's also the oldest still-open issue on the repo, which gives you a sense of how obvious this was and how long it was wanted for.

    It's basically impossible to do this at the runtime level. I really want to see this work at the language level.

    I propose some syntax variation on the following:

    let third_party_module = @import("third_party_module", .{ permissions: .{ read: false } });
    

    I think permissions are one of those things that really needs to get locked in before a ton of code gets written for wl.

    I think there's definitely some usability quirks to work out. Here's just some tests we could run:

    • all permissions are enabled by default, can be denied from where they are imported from (very leaky) – I think this is a bad idea
    • all permissions are denied by default, module imports inherit permissions from where they are imported from – maybe a good idea
    • all permissions are denied by default, module imports don't inherit permissions at all. all permissions must explicitly be granted to them – probably the tightest security model

    Milestone: 0.3.0.

  • avatar~lino-levan changed the status to Todo · 2 years ago
  • avatar~clovercommented · 2 years ago
    Owner

    some meta permissions, regarding things like usage of @extern, unsafe, etc, might be interesting to consider too.

  • avatar~jeff-hykincommented · 2 years ago

    note: considering swapping out side-effect stuff for the imported module

    rough idea example:

    const myLog = ()=>0
    const myConsole = {...console, log: myLog}
    import "thing.js" with { swap: { console: myConsole }}
    

    @ paperdave's note:

    both of these need to solve the problem of "what happens when two modules, specifically of different permission sets, import the same module"

  • avatar~jeff-hykincommented · 2 years ago

    @lino-levan I have an idea you'll probably find exciting, and I could use help thinking about it.

    what happens when two modules, specifically of different permission sets, import the same module

    There's three ways I can imagine for dealing with this^ problem, with only the last one being exciting (IMO).

    1. Forking. E.g. "fork" an import and its basically treated as a new code / a different import. Then do stuff like change permisions on that import-fork
    2. Files-as-functions; treat each import as an argument. E.g. importing the same module twice is possible (and doesn't behave like deno)
    3. Bound imports; the code is only loaded once, but it will use different refereces depending on who the caller is

    I think the behivorial differences can be shown with this example:

    a_lib.wl

    
    //
    // setter/getter for a
    //
    var a = ""
    export fn getA() {
       return a
    }
    export fn setA(val) {
        @external("console").log("setting a_lib.a to be: "+val)
        a = val
    }
    
    
    //
    // setter/getter for b
    //
    var b = fn (stringArg) { return "" }
    export fn getB() {
       return b
    }
    export fn setB(val) {
        @external("console").log("setting a_lib.b to be: "+val)
        b = val
    }
    export fn callB(val) {
        return b(val)
    }
    
    //
    // main
    //
    var mainDidRun = false
    export fn main() {
        if (!mainDidRun) {
            mainDidRun = true
            setA("init")
            setB(@external("fetch"))
        }
    }
    

    location1.wl

    const aLib1 = @import("a_lib.wl")
    export fn getA() {
        return aLib1.getA()
    }
    export fn callB(val) {
        return aLib1.callB(val)
    }
    export fn main() {
        aLib1.main()
        aLib1.setA("loc1")
    }
    

    location2.wl

    const aLib2 = @import("a_lib.wl")
    export fn getA() {
        return aLib2getA()
    }
    export fn main() {
        aLib2.main()
        aLib2.setA("loc2")
    }
    

    main.wl

    const loc1 = @import("location1.wl") // full permissions
    const loc2 = @import("location2.wl") // full permissions
    const aLib3 = @import("a_lib.wl", .{ permissions: .{ fetch: false } }) // restricted
    fn main() {
        loc1.main()
        loc2.main()
        aLib3.main()
        @extern("console").log("value equal?", loc1.getA() == loc2.getA())
        @extern("console").log("addr equal?",  @memAddress(loc1.getA()) == @memAddress(loc2.getA()) )
        @extern("console").log("value equal?", loc2.getA() == aLib3.getA())
        @extern("console").log("addr equal?",  @memAddress(loc2.getA()) == @memAddress(aLib3.getA()) )
    
        loc1.callB("https://httpbin.org/anything")    
        aLib3.callB("https://httpbin.org/anything")
    }
    
    1. Forking would say that aLib1, and aLib2 are the same object, but aLib3 is different.
    2. Files-as-functions would say aLib1, aLib2, and aLib3 are all different. Each has their own a
    3. Bound imports tries to say that aLib1, aLib2, and aLib3 are all the same object, BUT have different attached contexts. Think of it like javascript .bind(). Replace @extern with this.@extern and then treat @import() as @import().bind({ custom thing })

    But you might already see the difficulty for permissions and Bound-imports:

    • loc1 (full permissions) runs a_lib's main first
    • because of the boolean check, aLib3.main() doesn't really run again
    • loc1's @extern allows accessing fetch, so it sets b = @extern("fetch")
    • then later aLib3 (restricted fetch permisisons) tries to run .callB
    • and, despite being restricted, and despite the main function not directly giving fetch to aLib3, aLib3 is able to call fetch inside of its own scope.

    its very unclear how permissions should work with lambda functions

  • avatar~clovercommented · 2 years ago
    Owner

    actually, before this can be properly considered, we need to have a baseline for what the permissions model will actually look like.

    read is an odd permission name, since this project is a compiler and not a runtime. actions such as reading a file are going to be implemented in terms of simpler primitives like @extern and @jsImport. if you have some arbitrary gate in std.fs to block or filter file reads, one could just create bindings themselves (worst case is getting a handle to globalThis and calling eval on it).

    a solution to that may be to consider unsafe code (calling/casting an opaque value, etc) a special permission that is to be treated as the upmost dangerous. then, make the rest of the permissions userland, where the read permission is enforced by std.fs using a set of new built-in functions specifically for asserting permissions are set correctly.

    some of this might also benefit from waiting until some standard library features like std.fs get implemented.

  • avatar~lino-levancommented · 2 years ago
    Author

    a solution to that may be to consider unsafe code (calling/casting an opaque value, etc) a special permission that is to be treated as the upmost dangerous. then, make the rest of the permissions userland, where the read permission is enforced by std.fs using a set of new built-in functions specifically for asserting permissions are set correctly.

    Yep. I want to clarify here though that "userland" means, in practice, the standard library.

  • avatar~clovercommented · 2 years ago
    Owner

    I want to clarify here though that "userland" means, in practice, the standard library.

    I don't think it has to be, but the standard library would likely be the largest user of such a feature.

  • avatar~jeff-hykincommented · 2 years ago

    worst case is getting a handle to globalThis and calling eval on it).

    Which is kinda why I was considering a model that patches what globals are accessible. Ex: being unable to call @extern

  • avatar~jeff-hykincommented · 2 years ago

    I know it might seem like a meme for me to ask, but is there a particular reason @extern stuff isn't a static import? Like something that must be top-level known at compile time. Seems kinda hard to manage permissions on something as "loose" as @extern(userInput)

  • avatar~lino-levancommented · 2 years ago
    Author

    @extern(VALUE) must be known at compile time (dave correct me if I'm wrong). That stops a lot of problems by itself.

  • avatar~jeff-hykincommented · 2 years ago

    Okay that helps significantly

  • avatar~jeff-hykincommented · 2 years ago

    This has a lot of (possibly good) consequences for the permission model

  • avatar~jeff-hykincommented · 2 years ago

    This should help for thinking about "what happens if a module is imported twice with different bindings"

    import absolutePath from "path_utils" ^this function actually depends on the host OS.

    import posixAbsolutePath from "path_utils" override { extern: { OS: "unix" } }
    import windowsAbsolutePath from "path_utils" override { extern: { OS: "win32" } }